diff --git a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx
index f71a10ef0d..85d4bc78f6 100644
--- a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx
+++ b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx
@@ -1,7 +1,6 @@
import {
Button,
- ChannelActionProvider,
- ChannelStateProvider,
+ ChannelInstanceProvider,
ComponentProvider,
Message,
useComponentContext,
@@ -12,7 +11,6 @@ import {
SettingsTabLayoutHeader,
} from '../SettingsTabLayoutComponents.tsx';
import {
- reactionsPreviewChannelActions,
reactionsPreviewChannelState,
reactionsPreviewMessage,
reactionsPreviewOptions,
@@ -123,24 +121,26 @@ export const ReactionsTab = ({ close }: ReactionsTabProps) => {
Preview
-
-
-
-
-
-
-
-
-
+ {/* MERGE-RECONCILE: PR #2909 replaced the deleted ChannelState/ChannelAction
+ providers with ChannelInstanceProvider (Message now reads useChannel()). */}
+
+
+
+
+
+
+
diff --git a/examples/vite/src/ChannelPreviewOverlay/ChannelPreviewOverlay.tsx b/examples/vite/src/ChannelPreviewOverlay/ChannelPreviewOverlay.tsx
index fcb1b52dcc..21d60cdc8d 100644
--- a/examples/vite/src/ChannelPreviewOverlay/ChannelPreviewOverlay.tsx
+++ b/examples/vite/src/ChannelPreviewOverlay/ChannelPreviewOverlay.tsx
@@ -4,8 +4,8 @@ import {
Button,
IconMessageBubbles,
LoadingIndicator,
+ useChannel,
useChannelMembersState,
- useChannelStateContext,
useChatContext,
useNotificationApi,
} from 'stream-chat-react';
@@ -14,7 +14,7 @@ import './ChannelPreviewOverlay.scss';
export const useChannelMembershipState = () => {
const { client } = useChatContext();
- const { channel } = useChannelStateContext();
+ const channel = useChannel();
const members = useChannelMembersState(channel);
const membership = members[client.userID!] as ChannelMemberResponse | undefined;
diff --git a/examples/vite/src/ChatLayout/AlsoSentInChannelIndicator.tsx b/examples/vite/src/ChatLayout/AlsoSentInChannelIndicator.tsx
new file mode 100644
index 0000000000..b66e7dfdee
--- /dev/null
+++ b/examples/vite/src/ChatLayout/AlsoSentInChannelIndicator.tsx
@@ -0,0 +1,31 @@
+import {
+ MessageAlsoSentInChannelIndicator,
+ useMessageAlsoSentInChannelNavigation,
+} from 'stream-chat-react';
+import { useSlotGeometry } from 'stream-chat-react/slot-geometry';
+
+import { MAIN_CHANNEL_SLOT } from './constants';
+
+/**
+ * Coverage-aware "Also sent in channel" indicator. It reuses the SDK default component and its
+ * navigation hook — no logic or markup is copied — and only records a one-shot intent (on the
+ * geometry provider) to reveal the channel column when jumping to it from inside a reply thread.
+ *
+ * The actual "close the covering thread" decision is deferred to a channels-view effect
+ * (in `ResponsiveChannelPanels`), because it must run *after* navigation: in the cross-view
+ * case (threads → channels) the channel isn't mounted or measurable at click time. The effect closes
+ * the thread only when the geometry plugin reports the channel is actually obscured, so wide
+ * side-by-side layouts keep the thread. This scopes the behavior to this navigation only — it does
+ * not change global channel-open semantics.
+ */
+export const AlsoSentInChannelIndicator = () => {
+ const { isInThread, viewReference } = useMessageAlsoSentInChannelNavigation();
+ const { requestReveal } = useSlotGeometry();
+
+ const onView = async () => {
+ if (isInThread) requestReveal(MAIN_CHANNEL_SLOT);
+ await viewReference();
+ };
+
+ return
;
+};
diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx
index f1f479d679..d1582044b8 100644
--- a/examples/vite/src/ChatLayout/Panels.tsx
+++ b/examples/vite/src/ChatLayout/Panels.tsx
@@ -1,35 +1,80 @@
import clsx from 'clsx';
-import type { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat';
-import { useEffect, useRef } from 'react';
+import type {
+ ChannelMemberResponse,
+ Channel as StreamChannel,
+ Thread as ThreadType,
+ UserResponse,
+} from 'stream-chat';
+import {
+ type ComponentType,
+ type MouseEvent,
+ type PropsWithChildren,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import {
AIStateIndicator,
+ Button,
Channel,
ChannelAvatar,
ChannelHeader,
- ChannelList,
+ ChannelListItem,
+ ChannelSearchResultItem,
ChatView,
type ChatViewSelectorEntry,
+ EmptyStateIndicator,
+ IconArrowLeft,
+ IconXmark,
MessageComposer,
MessageList,
+ MessageSearchResultItem,
+ ModalContextProvider,
+ SearchSourceResultList,
+ type SearchSourceResultListProps,
Thread,
+ type ThreadHeaderProps,
ThreadList,
+ ThreadProvider,
+ ThreadSlot,
TypingIndicator,
- useChannelStateContext,
+ useActiveThread,
useChatContext,
- useThreadsViewContext,
+ useChatViewNavigation,
+ UserSearchResultItem,
+ useSlotChannel,
+ useSlotThread,
+ useSlotTopLayerEntity,
+ useTranslationContext,
VirtualizedMessageList,
- Window,
WithComponents,
WithDragAndDropUpload,
} from 'stream-chat-react';
+import {
+ ChannelDetailProvider,
+ ChannelMemberDetail,
+} from 'stream-chat-react/channel-detail';
import { useAppSettingsSelector } from '../AppSettings/state';
import { ConfiguredAvatarWithChannelDetail } from './ConfiguredChannelDetail.tsx';
-import { DESKTOP_LAYOUT_BREAKPOINT } from './constants.ts';
+import {
+ resolveRevealAction,
+ useRegisterSlotGeometry,
+ useSlotGeometry,
+} from 'stream-chat-react/slot-geometry';
+import {
+ CHANNEL_THREAD_SLOT,
+ DESKTOP_LAYOUT_BREAKPOINT,
+ MAIN_CHANNEL_SLOT,
+ MAIN_THREAD_SLOT,
+ OPTIONAL_THREAD_SLOT,
+} from './constants.ts';
import { SidebarResizeHandle, ThreadResizeHandle } from './Resize.tsx';
import { ReturnToSkipNavigation } from '../AccessibilityNavigation/ReturnToSkipNavigation.tsx';
import { ChannelPreviewOverlay } from '../ChannelPreviewOverlay/ChannelPreviewOverlay.tsx';
import { useSidebar } from './SidebarContext.tsx';
+import { SwitchableChannelNavigation } from './SwitchableChannelNavigation.tsx';
import { ThreadStateSync } from './Sync.tsx';
export const CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID =
@@ -38,94 +83,568 @@ export const CHANNEL_LIST_TARGET_ID = 'app-channel-list';
export const CHANNELS_SELECTOR_BUTTON_TARGET_QUERY =
'[id^="str-chat__chat-view-"][id$="-tab-channels"]';
-const ChannelThreadPanel = () => {
- const { thread } = useChannelStateContext('ChannelThreadPanel');
- const isOpen = !!thread;
+// Whether the app-settings "message list" toggle is set to the virtualized list. Read the same
+// way everywhere so every channel and thread honors the setting (not just the primary channel).
+const useVirtualizedMessageList = () =>
+ useAppSettingsSelector((s) => s.messageList).type === 'virtualized';
+
+// Shared sidebar overlay: the view selector (nav rail) + the view-specific list. Extracted
+// so the selector isn't duplicated across the channels/threads panels. (It still renders
+// per active view — the layout CSS scopes the collapsible overlay to each view's layout, so
+// a single always-mounted rail would need a separate CSS refactor.)
+const ChatSidebar = ({
+ children,
+ iconOnly,
+ itemSet,
+}: PropsWithChildren<{ iconOnly?: boolean; itemSet?: ChatViewSelectorEntry[] }>) => (
+
+
+ {children}
+
+);
+
+// Channel list item whose click opens the channel via ChatView navigation. A plain click
+// opens it in the primary slot (default resolution); ctrl/⌘-click opens it beside the
+// current channel, in the secondary slot where the reply thread normally sits.
+const CustomChannelListItem = ({ item }: { item: unknown }) => {
+ const channel = item as StreamChannel;
+ const { open } = useChatViewNavigation();
+ const openChannel = (event: MouseEvent) => {
+ const binding = {
+ key: channel.cid ?? undefined,
+ kind: 'channel' as const,
+ source: channel,
+ };
+ // ctrl/⌘-click opens the channel beside the current one (secondary slot); a plain click
+ // opens it in place. `additive` lets the navigation layer pick the secondary slot rather
+ // than hard-coding its name here.
+ open(binding, {
+ // ⌘/ctrl-click opens beside the current channel (secondary slot). The SDK base policy
+ // binds it there if free, or stacks a layer if the slot is occupied (e.g. an open reply
+ // thread) — no explicit `layer` needed.
+ additive: event.ctrlKey || event.metaKey,
+ });
+ };
+ return
;
+};
+// Search result items wired for ⌘/Ctrl-click here in the app (the SDK items default to a plain
+// open; the "open beside" UX decision belongs to the app). Plain click opens in place;
+// ctrl/⌘-click opens the result beside the current channel (additive -> secondary slot).
+const SearchChannelResult = ({ item }: { item: StreamChannel }) => {
+ const { open } = useChatViewNavigation();
return (
- <>
-
-
+
+ open(
+ { key: item.cid ?? undefined, kind: 'channel', source: item },
+ {
+ // ⌘/ctrl-click opens beside the current channel (secondary slot). The SDK base policy
+ // binds it there if free, or stacks a layer if the slot is occupied (e.g. an open reply
+ // thread) — no explicit `layer` needed.
+ additive: event.ctrlKey || event.metaKey,
+ },
+ )
+ }
+ />
+ );
+};
+
+const SearchUserResult = ({ item }: { item: UserResponse }) => {
+ const { open } = useChatViewNavigation();
+ const { client } = useChatContext();
+ return (
+ {
+ const channel = client.channel('messaging', {
+ members: [client.userID as string, item.id],
+ });
+ void channel.watch();
+ open(
+ { key: channel.cid ?? undefined, kind: 'channel', source: channel },
+ {
+ // ⌘/ctrl-click opens beside the current channel (secondary slot). The SDK base policy
+ // binds it there if free, or stacks a layer if the slot is occupied (e.g. an open reply
+ // thread) — no explicit `layer` needed.
+ additive: event.ctrlKey || event.metaKey,
+ },
+ );
+ }}
+ />
+ );
+};
+
+// Injects the ⌘/Ctrl-aware channel/user result items (messages keep the default). Provided to
+// the channels view via ComponentContext (overrides the SDK's SearchSourceResultList).
+const SplitAwareSearchResultList = (props: SearchSourceResultListProps) => (
+
+);
+
+// The in-channel reply thread is opened into the channels view's secondary slot (the same
+// slot a 2nd channel can occupy) — bound by the message "reply in thread" / replies-count
+// actions via `open`. It renders only when a thread is bound; `ThreadProvider` feeds it to
+// , and `useActiveThread` activates/deactivates it with window focus.
+const ChannelThreadPanel = ({
+ onOpenMemberDetail,
+ thread,
+}: {
+ // Clicking an avatar/mention in the thread opens the member profile as a layer over this panel.
+ onOpenMemberDetail: (userId?: string) => void;
+ thread?: ThreadType;
+}) => {
+ const isOpen = !!thread;
+ const virtualized = useVirtualizedMessageList();
+ const registerThreadSlot = useRegisterSlotGeometry(CHANNEL_THREAD_SLOT);
+ useActiveThread({ activeThread: thread });
+
+ const panelClassName = clsx(
+ 'str-chat__dropzone-root--thread app-chat-secondary-panel',
+ { 'app-chat-secondary-panel--open': isOpen },
+ );
+
+ // The resize handle is rendered once at the slot level (see `ResponsiveChannelPanels`), not
+ // here — the bar belongs to the slot, which may hold layers, not to this base content.
+ return thread ? (
+ // ThreadProvider wraps the dropzone so WithDragAndDropUpload's `useChannel` resolves
+ // the thread's own channel — the thread panel no longer depends on an ambient
+ // , so it can live in its own slot beside the primary channel.
+
+
+ onOpenMemberDetail(mentionedUsers[0]?.id),
+ onUserClick: (_event, user) => onOpenMemberDetail(user.id),
+ }}
+ virtualized={virtualized}
/>
- >
+
+ ) : (
+ // Closed state: just the collapsed-width shell (no dropzone, so no channel needed).
+ // Registered with the geometry module; the open branch above is a WithDragAndDropUpload
+ // (no ref forwarding), but there the primary channel collapses so `isObscured` still fires.
+
);
};
-const ResponsiveChannelPanels = () => {
- const { thread } = useChannelStateContext('ResponsiveChannelPanels');
- const isThreadOpen = !!thread;
- const { type: messageListType } = useAppSettingsSelector((s) => s.messageList);
+// Header affordance for the secondary channel, injected into its ChannelHeader via the
+// `HeaderStartContent` component slot (ChannelHeader has no built-in one the way the reply thread's
+// ThreadHeader does). Both variants call `close(CHANNEL_THREAD_SLOT)` — which pops the top layer
+// if any, else releases the base (layer-aware close). The icon just signals intent: a **back**
+// arrow when the channel is a layer over something to return to, a **close** X otherwise.
+const SecondaryChannelHeaderButton = ({ variant }: { variant: 'back' | 'close' }) => {
+ const { close } = useChatViewNavigation();
+ const isBack = variant === 'back';
return (
- close(CHANNEL_THREAD_SLOT)}
+ size='md'
+ variant='secondary'
>
-
-
+ {isBack ? : }
+
+ );
+};
+
+const SecondaryChannelCloseButton = () => (
+
+);
+const SecondaryChannelBackButton = () => ;
+
+// The 2nd channel's content: its own (a sibling of the primary channel, not nested)
+// with header + list + composer inside the dropzone so WithDragAndDropUpload's `useChannel`
+// resolves it. Shared by the base side-by-side panel and the layer overlay below. The close
+// affordance is injected into the header via HeaderStartContent (`close(CHANNEL_THREAD_SLOT)`
+// releases the base or pops the layer, per the layer-aware close).
+const SecondChannelContent = ({
+ channel,
+ HeaderStartContent = SecondaryChannelCloseButton,
+}: {
+ channel: StreamChannel;
+ HeaderStartContent?: ComponentType;
+}) => {
+ const virtualized = useVirtualizedMessageList();
+ return (
+
+
+
- {messageListType === 'virtualized' ? (
+ {virtualized ? (
) : (
)}
-
-
-
-
+
-
-
-
+
+
+
+ );
+};
+
+// Shared wrapper for a layer covering the secondary slot: mirrors the channel container's
+// `.str-chat` + theme root (so `--str-chat__*` tokens and component styles resolve) and carries
+// the opaque, bordered `.app-chat-secondary-overlay` surface. The base panel stays mounted beneath.
+const SecondarySlotOverlay = ({ children }: PropsWithChildren) => {
+ const { theme } = useChatContext();
+ return (
+
+ {children}
+
+ );
+};
+
+// The secondary channels-view slot can hold a 2nd channel side-by-side as its BASE binding
+// (⌘/ctrl-click a channel into a free secondary slot). It renders in the same column the reply
+// thread uses.
+const SecondChannelPanel = ({ channel }: { channel: StreamChannel }) => {
+ const registerThreadSlot = useRegisterSlotGeometry(CHANNEL_THREAD_SLOT);
+ // The resize handle is rendered once at the slot level (see `ResponsiveChannelPanels`).
+ return (
+
+
+
+ );
+};
+
+// A 2nd channel opened as a LAYER over the slot's current content — ⌘/ctrl-click a channel while a
+// reply thread (or another panel) already occupies the secondary slot. Enabled by the SDK's
+// `open(binding, { additive: true, layer: true })` base policy: it covers the base without closing
+// it, and `close(CHANNEL_THREAD_SLOT)` pops the layer to reveal what was underneath.
+const SecondChannelOverlay = ({
+ channel,
+ hasBaseBeneath,
+}: {
+ channel: StreamChannel;
+ /** When something sits under this layer (a reply thread / another panel), the header shows a
+ * "back" affordance instead of "close" — both pop this layer to reveal it. */
+ hasBaseBeneath: boolean;
+}) => (
+
+
+
+);
+
+// A thread overlay is always a layer stacked over the secondary slot's base, so its header gets a
+// BACK affordance rather than a close: it pops the layer (`close` is layer-aware), revealing what's
+// beneath. The SDK ThreadHeader hardcodes a close-X with no icon override, so we render a small
+// header of our own (back + title) here.
+const LayerThreadHeader = ({ thread }: ThreadHeaderProps) => {
+ const { close } = useChatViewNavigation();
+ const { t } = useTranslationContext();
+ const replyCount = thread?.reply_count ?? 0;
+
+ return (
+
+
+
+
+
+
{t('Thread')}
+
+ {replyCount === 1
+ ? t('1 reply')
+ : t('{{ count }} replies', { count: replyCount })}
+
+
+
+ );
+};
+
+// A reply thread shown as a LAYER over the secondary slot — e.g. clicking the replies button inside
+// a 2nd channel that occupies the slot. The SDK base policy stacks the thread on top (rather than
+// binding it invisibly beneath); here we render that top layer in the shared overlay wrapper, with
+// a header close that pops the layer to reveal the channel beneath.
+const ChannelThreadOverlay = ({ thread }: { thread: ThreadType }) => {
+ const virtualized = useVirtualizedMessageList();
+ useActiveThread({ activeThread: thread });
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+// A member profile shown over the secondary slot — opened by clicking a message avatar or a mention
+// in either the primary channel or the reply thread (see `ResponsiveChannelPanels` /
+// `ChannelThreadPanel`). It's a `pushLayer` overlay, not a slot replacement: it renders absolutely
+// over the secondary column (`.app-chat-secondary-overlay`) while the base panel stays mounted
+// beneath, so `close`/back (which pop the layer) reveal the thread exactly where it was.
+//
+// We reuse the `userProfile` slot kind (source `{ userId }`) rather than adding a new SDK kind; the
+// member is resolved here from the channel it belongs to. `ChannelMemberDetail` is built for the
+// ChannelDetail navigator/modal, so we feed it the two contexts it expects standalone:
+// `ChannelDetailProvider` (its member actions read the channel from `useChannelDetailContext`) and a
+// `ModalContextProvider` whose `close` pops the layer (the detail header's close button calls it).
+const ChannelMemberDetailOverlay = ({
+ channel,
+ hasBaseBeneath,
+ userId,
+}: {
+ channel: StreamChannel;
+ /** Whether a thread / 2nd channel sits under this overlay — if so, offer a "back" affordance
+ * (both back and close pop this single layer to reveal it). */
+ hasBaseBeneath: boolean;
+ userId: string;
+}) => {
+ const { close } = useChatViewNavigation();
+ const [member, setMember] = useState
(
+ () => channel.state.members[userId],
+ );
+
+ useEffect(() => {
+ const existing = channel.state.members[userId];
+ if (existing) {
+ setMember(existing);
+ return;
+ }
+ // Not a cached member (e.g. large channel) — fetch just this one.
+ let cancelled = false;
+ channel
+ .queryMembers({ id: { $in: [userId] } })
+ .then((response) => {
+ if (!cancelled) setMember(response.members[0]);
+ })
+ .catch(() => undefined);
+ return () => {
+ cancelled = true;
+ };
+ }, [channel, userId]);
+
+ // Pops the member-profile layer (per the layer-aware `close`), revealing the base beneath.
+ const closeOverlay = useCallback(() => close(CHANNEL_THREAD_SLOT), [close]);
+
+ return (
+
+ {member ? (
+
+
+
+
+
+ ) : null}
+
+ );
+};
+
+const ResponsiveChannelPanels = ({ mainChannel }: { mainChannel?: StreamChannel }) => {
+ // Register the primary channel column with the geometry module so `isObscured(MAIN_CHANNEL_SLOT)`
+ // reflects reality (at narrow widths the open secondary panel collapses this column to ~0).
+ const registerMainSlot = useRegisterSlotGeometry(MAIN_CHANNEL_SLOT);
+ // The channel and the thread each occupy their OWN slot and render as siblings here: the
+ // primary channel wraps only its own message panel, and the secondary slot — a reply
+ // thread or a 2nd channel — sits beside it rather than nested inside the channel. A bound
+ // Thread carries its own channel, so it no longer depends on an ambient .
+ const sideChannel = useSlotChannel({ slot: CHANNEL_THREAD_SLOT });
+ const replyThread = useSlotThread({ slot: CHANNEL_THREAD_SLOT });
+ // A member profile can also occupy the secondary slot — but as a *layer* on top of whatever is
+ // there (a reply thread, a 2nd channel, or nothing), not by replacing it. Reading the top layer
+ // (not the base binding) is what lets the thread beneath stay mounted so closing the profile
+ // returns to it at its exact scroll position. We reuse the `userProfile` kind (source `{ userId }`).
+ const memberProfile = useSlotTopLayerEntity({
+ kind: 'userProfile',
+ slot: CHANNEL_THREAD_SLOT,
+ });
+ // A channel opened as a layer (⌘/ctrl-click a channel while the secondary slot is occupied) — the
+ // top layer's `channel` source. Covers the base (e.g. a reply thread) without closing it.
+ const layerChannel = useSlotTopLayerEntity({
+ kind: 'channel',
+ slot: CHANNEL_THREAD_SLOT,
+ });
+ // A reply thread opened as a layer (e.g. replies clicked inside a 2nd channel occupying the slot)
+ // — the top layer's `thread` source. Covers the base without closing it.
+ const layerThread = useSlotTopLayerEntity({
+ kind: 'thread',
+ slot: CHANNEL_THREAD_SLOT,
+ });
+ const isSideOpen =
+ !!sideChannel || !!replyThread || !!memberProfile || !!layerChannel || !!layerThread;
+ const virtualized = useVirtualizedMessageList();
+ const { close, pushLayer } = useChatViewNavigation();
+
+ // Coverage-driven "reveal": when a navigation asked to reveal the channel (e.g. "Also sent in
+ // channel → View" from the threads view — see AlsoSentInChannelIndicator), release the covering
+ // thread once this view has mounted and the geometry plugin has measured the channel column.
+ // Deferring to here — rather than the click handler — is what makes the cross-view case work:
+ // the channel isn't mounted/measurable until after the view switch. `resolveRevealAction` keeps
+ // it coverage-conditional (wide side-by-side layouts leave the thread) and one-shot.
+ const { clearReveal, coverage, revealSlot } = useSlotGeometry();
+ useEffect(() => {
+ const action = resolveRevealAction(revealSlot, coverage);
+ if (action === 'wait') return;
+ if (action === 'act') close(CHANNEL_THREAD_SLOT);
+ clearReveal();
+ }, [clearReveal, close, coverage, revealSlot]);
+
+ // Open a member's detail as a layer covering the secondary slot. The clicked user's id becomes the
+ // `userProfile` source, which `ChannelMemberDetailOverlay` resolves back to a channel member.
+ const openMemberDetail = useCallback(
+ (userId?: string) => {
+ if (!userId) return;
+ pushLayer(CHANNEL_THREAD_SLOT, {
+ key: userId,
+ kind: 'userProfile',
+ source: { userId },
+ });
+ },
+ [pushLayer],
+ );
+
+ return (
+
+ {mainChannel && (
+ // The main column wrapper carries `.app-chat-view__channel-main` (the existing layout
+ // rules size it); the primary
lives inside it — a sibling of the secondary
+ // panel below, not its ancestor.
+
+
+
+
+
+ {virtualized ? (
+ // VirtualizedMessageList drills a narrower prop set and doesn't forward
+ // onUserClick/onMentionsClick — wiring member-detail there would need a custom
+ // Message component. The demo's default list is the standard one below.
+
+ ) : (
+
+ openMemberDetail(mentionedUsers[0]?.id)
+ }
+ onUserClick={(_event, user) => openMemberDetail(user.id)}
+ returnAllReadData
+ />
+ )}
+
+
+
+
+
+
+
+
+ )}
+ {/* The resize handle belongs to the SLOT, not its contents: it's rendered once here and
+ driven by whether the slot is open (base binding OR a layer such as the member profile),
+ so a layer that covers the base — or is the only occupant — stays resizable. */}
+
+ {/* The base of the secondary slot (2nd channel or reply thread) is ALWAYS rendered at a
+ stable position so it stays mounted — a member-profile layer covers it (below) rather
+ than replacing it, and closing that layer reveals it untouched. */}
+ {sideChannel ? (
+
+ ) : (
+
+ )}
+ {memberProfile && mainChannel && (
+
+ )}
+ {/* A ⌘/ctrl-clicked channel opened as a layer covers the secondary slot; its header shows a
+ back affordance when a base (reply thread / 2nd channel) sits beneath, else a close — both
+ pop the layer, revealing what was underneath. */}
+ {layerChannel && (
+
+ )}
+ {/* A reply thread opened as a layer (e.g. the replies button inside a 2nd channel that holds
+ the slot) covers it; its header close pops the layer to reveal the channel beneath. */}
+ {layerThread && }
);
};
export const ChannelsPanels = ({
- filters,
iconOnly,
- initialChannelId,
itemSet,
- options,
- sort,
}: {
- filters: ChannelFilters;
iconOnly?: boolean;
initialChannelId?: string;
itemSet?: ChatViewSelectorEntry[];
- options: ChannelOptions;
- sort: ChannelSort;
}) => {
- const { channel } = useChatContext('ChannelsPanels');
+ // The primary channel occupies the main panel; a 2nd channel (ctrl/⌘-click) or the reply
+ // thread shares the secondary slot, rendered by ResponsiveChannelPanels.
+ const mainChannel = useSlotChannel({ slot: MAIN_CHANNEL_SLOT });
+ const mainChannelId = mainChannel?.id;
const { closeSidebar, sidebarOpen } = useSidebar();
const channelsLayoutRef = useRef(null);
useEffect(() => {
- if (!channel?.id || typeof window === 'undefined') return;
+ if (!mainChannelId || typeof window === 'undefined') return;
if (window.innerWidth >= DESKTOP_LAYOUT_BREAKPOINT) return;
closeSidebar();
- }, [channel?.id, closeSidebar]);
+ }, [mainChannelId, closeSidebar]);
useEffect(() => {
const channelListElement = channelsLayoutRef.current?.querySelector(
@@ -134,42 +653,85 @@ export const ChannelsPanels = ({
if (!channelListElement) return;
channelListElement.id = CHANNEL_LIST_TARGET_ID;
- }, [channel?.id, sidebarOpen]);
+ }, [mainChannelId, sidebarOpen]);
return (
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// Renders the primary open thread, bound to the main thread slot. `ThreadSlot` supplies the
+// ThreadProvider + slot context (so the header's close button knows which slot it releases),
+// and `useActiveThread` keeps the thread activated/deactivated with window focus.
+const ThreadPanel = ({ thread }: { thread: ThreadType }) => {
+ const virtualized = useVirtualizedMessageList();
+ useActiveThread({ activeThread: thread });
+
+ return (
+
+
+
+
+
- undefined}
- options={options}
- showChannelSearch
- sort={sort}
+ virtualized={virtualized}
+ />
+
+
+
+ );
+};
+
+// The threads view's secondary slot holds a 2nd thread (ctrl/⌘-click a thread in the list)
+// opened side-by-side with the primary thread. It occupies the resizable secondary column.
+// `ThreadSlot` binds it to OPTIONAL_THREAD_SLOT, which is why its ThreadHeader shows the
+// built-in close button (end content) — the same header the channels-view reply thread uses.
+const SecondaryThreadPanel = ({ thread }: { thread: ThreadType }) => {
+ const virtualized = useVirtualizedMessageList();
+ useActiveThread({ activeThread: thread });
+
+ return (
+
+
+
+
+
+
-
-
-
-
-
-
-
+
-
+
);
};
@@ -181,41 +743,46 @@ export const ThreadsPanels = ({
itemSet?: ChatViewSelectorEntry[];
}) => {
const { sidebarOpen } = useSidebar();
- const { activeThread } = useThreadsViewContext();
+ // The primary thread fills the main column; an optional 2nd thread (ctrl/⌘-click) occupies
+ // the resizable secondary column beside it — the same slot machinery the channels view uses.
+ const mainThread = useSlotThread({ slot: MAIN_THREAD_SLOT });
+ const optionalThread = useSlotThread({ slot: OPTIONAL_THREAD_SLOT });
+ const hasThread = !!mainThread || !!optionalThread;
+ const isSecondaryOpen = !!optionalThread;
const threadsLayoutRef = useRef(null);
return (
-
+ <>
-
-
+
-
+
-
-
-
-
-
-
-
-
-
+
+ {!hasThread ? (
+
+
+
+ ) : (
+ <>
+ {mainThread &&
}
+
+ {optionalThread &&
}
+ >
+ )}
-
+ >
);
};
diff --git a/examples/vite/src/ChatLayout/Resize.tsx b/examples/vite/src/ChatLayout/Resize.tsx
index ae23d671b3..9472bf43e3 100644
--- a/examples/vite/src/ChatLayout/Resize.tsx
+++ b/examples/vite/src/ChatLayout/Resize.tsx
@@ -83,7 +83,7 @@ const getAppLayoutElement = (element?: HTMLElement | null) => {
const setPanelWidthCssVariable = (
appLayoutElement: HTMLDivElement,
- cssVariableName: '--app-left-panel-width' | '--app-thread-panel-width',
+ cssVariableName: '--app-left-panel-width' | '--app-secondary-panel-width',
width: number,
) => {
appLayoutElement.style.setProperty(cssVariableName, `${width}px`);
@@ -125,7 +125,7 @@ export const PanelLayoutStyleSync = ({
);
setPanelWidthCssVariable(
layoutElement,
- '--app-thread-panel-width',
+ '--app-secondary-panel-width',
panelLayout.threadPanel.width,
);
}, [layoutRef, panelLayout]);
@@ -368,7 +368,7 @@ export const ThreadResizeHandle = ({ isOpen }: { isOpen: boolean }) => {
const isRtl = getComputedStyle(appLayoutElement).direction === 'rtl';
beginHorizontalResize({
- bodyClassName: 'app-chat-resizing-thread',
+ bodyClassName: 'app-chat-resizing-secondary',
handle: event.currentTarget,
onMove: (pointerEvent) => {
const nextWidth = isRtl
@@ -378,7 +378,11 @@ export const ThreadResizeHandle = ({ isOpen }: { isOpen: boolean }) => {
const width = clamp(nextWidth, THREAD_PANEL_MIN_WIDTH, maxWidth);
dragState.width = width;
- setPanelWidthCssVariable(appLayoutElement, '--app-thread-panel-width', width);
+ setPanelWidthCssVariable(
+ appLayoutElement,
+ '--app-secondary-panel-width',
+ width,
+ );
},
onStop: () => {
const previousWidth = threadPanelWidthRef.current;
@@ -402,8 +406,8 @@ export const ThreadResizeHandle = ({ isOpen }: { isOpen: boolean }) => {
return (
diff --git a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx
new file mode 100644
index 0000000000..6e839ad9a6
--- /dev/null
+++ b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx
@@ -0,0 +1,208 @@
+import { useEffect, useMemo, useState } from 'react';
+import type {
+ ChannelPaginator,
+ ChannelPaginatorsOrchestratorState,
+ ChannelPaginatorState,
+ SearchControllerState,
+} from 'stream-chat';
+import {
+ Button,
+ ChannelList,
+ ChannelListContextProvider,
+ ChannelListHeader,
+ ContextMenu,
+ ContextMenuButton,
+ NotificationList as DefaultNotificationList,
+ Search as DefaultSearch,
+ IconChevronDown,
+ useChatContext,
+ useComponentContext,
+ useDialogIsOpen,
+ useDialogOnNearestManager,
+ useStateStore,
+} from 'stream-chat-react';
+
+// Human-readable labels for each channel list, keyed by the paginator id set up in App.tsx.
+// The SDK doesn't name lists — an app that shows more than one decides how to label them, so
+// this map lives here in the example rather than in the SDK.
+const CHANNEL_LIST_LABELS: Record
= {
+ 'channels:default': 'My channels',
+ 'channels:archived': 'Archived',
+ 'channels:muted': 'Muted',
+ 'channels:opened': 'Opened',
+};
+const labelFor = (id: string) => CHANNEL_LIST_LABELS[id] ?? id;
+
+// The catch-all fallback: shown in the switcher menu only once it actually holds a channel.
+const FALLBACK_PAGINATOR_ID = 'channels:opened';
+
+const SWITCHER_DIALOG_ID = 'app-channel-list-switcher';
+
+const itemCountSelector = (state: ChannelPaginatorState) => ({
+ count: state.items?.length ?? 0,
+});
+
+const paginatorsSelector = (state: ChannelPaginatorsOrchestratorState) => ({
+ paginators: state.paginators,
+});
+const searchControllerStateSelector = (state: SearchControllerState) => ({
+ isActive: state.isActive,
+});
+
+// One menu entry per channel list. Subscribes to its paginator's state so the fallback can
+// appear/disappear reactively as it gains/loses channels (the parent only re-renders when the
+// set of paginators changes, not when a paginator's items do).
+const ChannelListMenuItem = ({
+ activeId,
+ onPick,
+ paginator,
+}: {
+ activeId: string;
+ onPick: (id: string) => void;
+ paginator: ChannelPaginator;
+}) => {
+ const { count } = useStateStore(paginator.state, itemCountSelector);
+
+ if (paginator.id === FALLBACK_PAGINATOR_ID && count === 0) return null;
+
+ return (
+ onPick(paginator.id)}
+ role='menuitemradio'
+ >
+ {labelFor(paginator.id)}
+
+ );
+};
+
+// Menu to switch between channel lists, built on the SDK's `ContextMenu` (anchored-dialog mode):
+// a trigger button is the reference element and the menu opens into the nearest DialogManager —
+// the same primitive the SDK uses for message/channel action menus, so it gets roving focus,
+// Escape/outside-click dismissal, and placement for free. Renders nothing when there's only one
+// list (nothing to switch to).
+const ChannelListSwitcher = ({
+ activeId,
+ onSelect,
+ paginators,
+}: {
+ activeId: string;
+ onSelect: (id: string) => void;
+ paginators: ChannelPaginator[];
+}) => {
+ const [referenceElement, setReferenceElement] = useState(
+ null,
+ );
+ const { dialog, dialogManager } = useDialogOnNearestManager({ id: SWITCHER_DIALOG_ID });
+ const dialogIsOpen = useDialogIsOpen(SWITCHER_DIALOG_ID, dialogManager?.id);
+
+ // Publish the trigger's width as a CSS var so the portalled ContextMenu can match it (the menu
+ // isn't a DOM descendant of the trigger, so it can't inherit the width; a root-level var can).
+ useEffect(() => {
+ if (!referenceElement) return;
+ const syncWidth = () =>
+ document.documentElement.style.setProperty(
+ '--app-channel-list-switcher-menu-width',
+ `${referenceElement.offsetWidth}px`,
+ );
+ syncWidth();
+ const observer = new ResizeObserver(syncWidth);
+ observer.observe(referenceElement);
+ return () => observer.disconnect();
+ }, [referenceElement]);
+
+ if (paginators.length < 2) return null;
+
+ return (
+
+
+ dialog.close()}
+ placement='bottom-end'
+ referenceElement={referenceElement}
+ trapFocus
+ >
+ {paginators.map((paginator) => (
+ {
+ onSelect(id);
+ dialog.close();
+ }}
+ paginator={paginator}
+ />
+ ))}
+
+
+ );
+};
+
+/**
+ * Example channel navigation that shows exactly ONE channel list at a time plus a menu to
+ * switch between the lists held by the `ChannelPaginatorsOrchestrator`. It mirrors the SDK's
+ * `ChannelNavigation` (header, search, notifications) but replaces the SDK's stacked
+ * `ChannelLists` (one `` per paginator, empty ones included) with a switcher +
+ * the active list. This keeps the empty "Opened" fallback from rendering below the primary
+ * list until the user actually switches to it.
+ */
+export const SwitchableChannelNavigation = () => {
+ const { NotificationList = DefaultNotificationList, Search = DefaultSearch } =
+ useComponentContext();
+ const { channelPaginatorsOrchestrator, searchController } = useChatContext();
+ const { paginators } = useStateStore(
+ channelPaginatorsOrchestrator.state,
+ paginatorsSelector,
+ );
+ const { isActive } = useStateStore(
+ searchController.state,
+ searchControllerStateSelector,
+ );
+
+ const [activeId, setActiveId] = useState(undefined);
+
+ // Default to the first list; if the selected list disappears (or none is selected yet),
+ // fall back to the first available paginator.
+ const activePaginator = useMemo(
+ () => paginators.find((paginator) => paginator.id === activeId) ?? paginators[0],
+ [paginators, activeId],
+ );
+
+ return (
+
+
+
+ {!isActive && activePaginator && (
+ // Expose the active paginator through ChannelListContext (as the SDK's ChannelLists
+ // does with its primary) so notification targeting resolves the 'channel-list' panel.
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/examples/vite/src/ChatLayout/Sync.tsx b/examples/vite/src/ChatLayout/Sync.tsx
index eddc107cf6..c2f51a221b 100644
--- a/examples/vite/src/ChatLayout/Sync.tsx
+++ b/examples/vite/src/ChatLayout/Sync.tsx
@@ -1,11 +1,12 @@
import { useEffect, useRef } from 'react';
-import type { ThreadManagerState } from 'stream-chat';
import {
type ChatView as ChatViewType,
useChatContext,
useChatViewContext,
- useStateStore,
- useThreadsViewContext,
+ useChatViewNavigation,
+ useLayoutViewState,
+ useSlotChannels,
+ useSlotThreads,
} from 'stream-chat-react';
const selectedChannelUrlParam = 'channel';
@@ -81,10 +82,40 @@ export const ChatStateSync = ({
}: {
initialChatView?: ChatViewType;
}) => {
- const { activeChatView, setActiveChatView } = useChatViewContext();
- const { channel, client } = useChatContext();
+ const { activeChatView, setActiveView } = useChatViewContext();
+ const { client } = useChatContext();
+ const { open } = useChatViewNavigation();
+ // Single-panel app: sync the primary (first) open channel slot to the URL. Read the
+ // *channels* view specifically (not the active view) so the `?channel=` param is
+ // retained while the threads view is focused — the channels layout keeps its binding.
+ const channel = useSlotChannels({ view: 'channels' })[0]?.channel;
const previousSyncedChatView = useRef(undefined);
const previousChannelId = useRef(undefined);
+ const restoredChannelFromUrl = useRef(false);
+
+ // Restore the active channel from the `?channel=` URL param on mount (previously
+ // done by the legacy ChannelList's `customActiveChannel`; the channel list is now
+ // orchestrator-driven, so URL restore is handled here — display flows through the
+ // channel slot bound by `open`).
+ useEffect(() => {
+ if (restoredChannelFromUrl.current || channel) return;
+ const channelIdFromUrl = getSelectedChannelIdFromUrl();
+ if (!channelIdFromUrl) return;
+ restoredChannelFromUrl.current = true;
+
+ // NB: no cancel-on-cleanup guard — under StrictMode the simulated unmount would
+ // otherwise cancel this restore while the ref already blocks the re-run. `open`
+ // targets the (always-mounted) layout slot state, so a late call is safe.
+ void (async () => {
+ let target = Object.values(client.activeChannels).find(
+ (candidate) => candidate.id === channelIdFromUrl,
+ );
+ if (!target) {
+ [target] = await client.queryChannels({ id: channelIdFromUrl });
+ }
+ if (target) open({ key: target.cid ?? undefined, kind: 'channel', source: target });
+ })();
+ }, [channel, client, open]);
useEffect(() => {
if (
@@ -92,7 +123,7 @@ export const ChatStateSync = ({
previousSyncedChatView.current === undefined &&
activeChatView !== initialChatView
) {
- setActiveChatView(initialChatView);
+ setActiveView(initialChatView);
return;
}
@@ -100,7 +131,7 @@ export const ChatStateSync = ({
previousSyncedChatView.current = activeChatView;
updateSelectedChatViewInUrl(activeChatView);
- }, [activeChatView, initialChatView, setActiveChatView]);
+ }, [activeChatView, initialChatView, setActiveView]);
useEffect(() => {
if (channel?.id) {
@@ -122,99 +153,53 @@ export const ChatStateSync = ({
return null;
};
-const threadManagerSelector = (nextValue: ThreadManagerState) => ({
- isLoading: nextValue.pagination.isLoading,
- ready: nextValue.ready,
- threads: nextValue.threads,
-});
-
export const ThreadStateSync = () => {
- const selectedThreadId = useRef(
+ const initialThreadId = useRef(
getSelectedThreadIdFromUrl() ?? undefined,
);
const { client } = useChatContext();
- const { activeThread, setActiveThread } = useThreadsViewContext();
- const { isLoading, ready, threads } = useStateStore(
- client.threads.state,
- threadManagerSelector,
- ) ?? {
- isLoading: false,
- ready: false,
- threads: [],
- };
- const isRestoringThread = useRef(false);
+ const { open } = useChatViewNavigation();
+ const { availableSlots } = useLayoutViewState();
+ // Single-panel app: the primary (first) thread slot drives the URL. A multi-panel
+ // app would sync differently (e.g. a focused slot).
+ const activeThread = useSlotThreads()[0]?.thread;
const previousThreadId = useRef(undefined);
- const attemptedThreadLookup = useRef(false);
+ const restoredThreadFromUrl = useRef(false);
+ // Restore the thread from the `?thread=` URL param, binding it into the primary
+ // thread slot once the threads layout exposes one. Mirrors ChatStateSync's channel
+ // restore — no cancel-on-cleanup guard so StrictMode's simulated unmount can't abort
+ // the async restore (the ref already blocks the re-run; `open` targets slot state).
+ useEffect(() => {
+ if (restoredThreadFromUrl.current || activeThread) return;
+ const threadIdFromUrl = initialThreadId.current;
+ if (!threadIdFromUrl) return;
+ if (!availableSlots.includes('main-thread')) return;
+ restoredThreadFromUrl.current = true;
+
+ void (async () => {
+ const thread = await client.getThread(threadIdFromUrl).catch(() => undefined);
+ if (!thread) return;
+ open(
+ { key: thread.id ?? undefined, kind: 'thread', source: thread },
+ { slot: 'main-thread' },
+ );
+ })();
+ }, [activeThread, availableSlots, client, open]);
+
+ // Keep the URL in sync with the primary thread slot.
useEffect(() => {
if (activeThread?.id) {
- selectedThreadId.current = activeThread.id;
previousThreadId.current = activeThread.id;
- attemptedThreadLookup.current = false;
updateSelectedThreadIdInUrl(activeThread.id);
return;
}
if (!previousThreadId.current) return;
- selectedThreadId.current = undefined;
previousThreadId.current = undefined;
- attemptedThreadLookup.current = false;
updateSelectedThreadIdInUrl();
}, [activeThread?.id]);
- useEffect(() => {
- const threadIdToRestore = selectedThreadId.current;
-
- if (!threadIdToRestore) return;
-
- if (activeThread?.id && activeThread.id !== threadIdToRestore) {
- return;
- }
-
- const matchingThreadFromList = threads.find(
- (thread) => thread.id === threadIdToRestore,
- );
-
- if (matchingThreadFromList && activeThread !== matchingThreadFromList) {
- setActiveThread(matchingThreadFromList);
- return;
- }
-
- if (
- matchingThreadFromList ||
- activeThread?.id === threadIdToRestore ||
- isRestoringThread.current ||
- attemptedThreadLookup.current ||
- isLoading ||
- !ready
- ) {
- return;
- }
-
- let cancelled = false;
-
- attemptedThreadLookup.current = true;
- isRestoringThread.current = true;
-
- client
- .getThread(threadIdToRestore)
- .then((thread) => {
- if (!thread || cancelled) return;
-
- setActiveThread(thread);
- })
- .catch(() => undefined)
- .finally(() => {
- if (cancelled) return;
-
- isRestoringThread.current = false;
- });
-
- return () => {
- cancelled = true;
- };
- }, [activeThread, client, isLoading, ready, setActiveThread, threads]);
-
return null;
};
diff --git a/examples/vite/src/ChatLayout/constants.ts b/examples/vite/src/ChatLayout/constants.ts
index 682295db10..17efbe405e 100644
--- a/examples/vite/src/ChatLayout/constants.ts
+++ b/examples/vite/src/ChatLayout/constants.ts
@@ -1,2 +1,14 @@
export const DESKTOP_LAYOUT_BREAKPOINT = 768;
export const MESSAGE_VIEW_MIN_WIDTH = 360;
+/** Primary channels-view slot (the main channel panel). */
+export const MAIN_CHANNEL_SLOT = 'main-channel';
+/** Secondary channels-view slot — holds either the in-channel reply Thread panel, or a
+ * 2nd channel opened side-by-side (ctrl/⌘-click a channel in the list). Shared by the
+ * ChatView layout descriptor and the panels that render whatever is bound. */
+export const CHANNEL_THREAD_SLOT = 'channel-thread';
+/** Primary threads-view slot (the main open thread). Must match the slot the SDK's
+ * ThreadListItemUI opens into on a plain click. */
+export const MAIN_THREAD_SLOT = 'main-thread';
+/** Secondary threads-view slot — a 2nd thread opened side-by-side (ctrl/⌘-click a thread in
+ * the list). Must match the slot ThreadListItemUI opens into on ctrl/⌘-click. */
+export const OPTIONAL_THREAD_SLOT = 'optional-thread';
diff --git a/examples/vite/src/LoadingScreen/LoadingScreen.tsx b/examples/vite/src/LoadingScreen/LoadingScreen.tsx
index ec56133f18..cdc94245e6 100644
--- a/examples/vite/src/LoadingScreen/LoadingScreen.tsx
+++ b/examples/vite/src/LoadingScreen/LoadingScreen.tsx
@@ -57,13 +57,9 @@ export const LoadingScreen = ({
-
-
diff --git a/examples/vite/src/PublicChannelOverlay/PublicChannelOverlay.tsx b/examples/vite/src/PublicChannelOverlay/PublicChannelOverlay.tsx
index 0de469fae7..9d268cfe36 100644
--- a/examples/vite/src/PublicChannelOverlay/PublicChannelOverlay.tsx
+++ b/examples/vite/src/PublicChannelOverlay/PublicChannelOverlay.tsx
@@ -4,8 +4,8 @@ import {
Button,
IconMessageBubbles,
LoadingIndicator,
+ useChannel,
useChannelMembersState,
- useChannelStateContext,
useChatContext,
useNotificationApi,
} from 'stream-chat-react';
@@ -14,7 +14,7 @@ import './PublicChannelOverlay.scss';
export const usePublicChannelState = () => {
const { client } = useChatContext();
- const { channel } = useChannelStateContext();
+ const channel = useChannel();
const members = useChannelMembersState(channel);
const membership = members[client.userID!] as ChannelMemberResponse | undefined;
diff --git a/examples/vite/src/SingleChannel/SingleChannel.scss b/examples/vite/src/SingleChannel/SingleChannel.scss
new file mode 100644
index 0000000000..c8bbcf4fea
--- /dev/null
+++ b/examples/vite/src/SingleChannel/SingleChannel.scss
@@ -0,0 +1,56 @@
+/* Single-channel scenario: a bare
rendered in a floating, draggable modal (the
+ `DraggableDialog` shell) over the full app. Sizes the floating window and lets the channel
+ fill it (header from the drag handle, then message list + composer). */
+
+/* Constrain the DialogAnchor root (the dialogClassName element) to the window width. Without
+ this it stretches to the full viewport, and DraggableDialog's drag clamp — which measures the
+ shell rect — computes a negative max and pins the window to the left edge (can't drag
+ horizontally). Mirrors how the WS-event dialog sizes its own root. */
+/* Give the DialogAnchor root a fixed size (not just width): a content-driven height let
+ floating-ui `shift` nudge the window up to the 8px viewport padding, so it opened at top:8
+ instead of the anchor's 20. A fixed root height fits within the viewport → no shift → the
+ window opens exactly at the anchor (20,20). */
+.app-single-channel-modal {
+ width: min(440px, calc(100vw - 32px));
+ height: min(640px, calc(100vh - 32px));
+}
+
+.app-single-channel-modal__prompt {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: min(640px, calc(100vh - 32px));
+ overflow: hidden;
+
+ .str-chat__prompt__header__title-group {
+ .str-chat__prompt__header__title,
+ .app__searchable-select__trigger {
+ width: 100%;
+ }
+ }
+}
+
+.app-single-channel-modal__drag-handle {
+ cursor: grab;
+ touch-action: none;
+}
+
+.app-single-channel-modal__drag-handle:active {
+ cursor: grabbing;
+}
+
+/* The channel + its message list/composer fill the remaining height below the drag handle. */
+.app-single-channel-modal__body,
+.app-single-channel-modal__prompt .str-chat__channel {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.app-single-channel-modal__prompt .str-chat__dropzone-root {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+}
diff --git a/examples/vite/src/SingleChannel/SingleChannelApp.tsx b/examples/vite/src/SingleChannel/SingleChannelApp.tsx
new file mode 100644
index 0000000000..cdd95513f9
--- /dev/null
+++ b/examples/vite/src/SingleChannel/SingleChannelApp.tsx
@@ -0,0 +1,166 @@
+import { useEffect, useMemo, useRef } from 'react';
+import type {
+ ChannelPaginatorsOrchestrator,
+ ChannelPaginatorsOrchestratorState,
+ Channel as StreamChannel,
+ StreamChat,
+} from 'stream-chat';
+import {
+ Channel,
+ MessageComposer,
+ MessageList,
+ useChatContext,
+ useDialogIsOpen,
+ useDialogOnNearestManager,
+ useStateStore,
+ WithDragAndDropUpload,
+} from 'stream-chat-react';
+
+import { DraggableDialog } from '../AppSettings/ActionsMenu/DraggableDialog';
+import {
+ SearchableSelect,
+ type SearchableSelectOption,
+} from '../AppSettings/SearchableSelect';
+import { appSettingsStore } from '../AppSettings/state';
+
+export const SINGLE_CHANNEL_DEFAULT_ID = 'general';
+const SINGLE_CHANNEL_DIALOG_ID = 'app-single-channel-modal';
+
+/**
+ * Resolve which channel the single-channel modal should render, in priority order:
+ * 1. An explicit key (`layout.channelCid` / `?channel=`): a full CID (`messaging:general`) or a
+ * bare id (assumed type `messaging`).
+ * 2. Otherwise the first channel already loaded by the paginators — a real, watched channel.
+ * 3. Otherwise a known demo channel.
+ */
+export const resolveSingleChannel = ({
+ channelKey,
+ client,
+ orchestrator,
+}: {
+ channelKey?: string;
+ client: StreamChat;
+ orchestrator?: ChannelPaginatorsOrchestrator;
+}): StreamChannel => {
+ if (channelKey) {
+ const separatorIndex = channelKey.indexOf(':');
+ const [type, id] =
+ separatorIndex === -1
+ ? ['messaging', channelKey]
+ : [channelKey.slice(0, separatorIndex), channelKey.slice(separatorIndex + 1)];
+ return client.channel(type, id);
+ }
+
+ const loadedChannel = orchestrator?.paginators.flatMap(
+ (paginator) => paginator.items ?? [],
+ )[0];
+ if (loadedChannel) return loadedChannel;
+
+ return client.channel('messaging', SINGLE_CHANNEL_DEFAULT_ID);
+};
+
+const channelDisplayName = (channel: StreamChannel) =>
+ (channel.data as { name?: string } | undefined)?.name ?? channel.id ?? channel.cid;
+
+const setSingleChannel = (channelCid: string | undefined) =>
+ appSettingsStore.partialNext({
+ layout: { ...appSettingsStore.getLatestValue().layout, channelCid },
+ });
+
+const paginatorsSelector = (state: ChannelPaginatorsOrchestratorState) => ({
+ paginators: state.paginators,
+});
+
+/**
+ * The modal's title: a channel switcher. Clicking it opens the searchable selector to switch which
+ * channel the single-channel modal renders (updates `layout.channelCid`; App re-resolves). Options
+ * are the channels the paginators have loaded, with the current channel always present.
+ */
+const SingleChannelTitle = ({ channel }: { channel: StreamChannel }) => {
+ const { channelPaginatorsOrchestrator } = useChatContext();
+ const { paginators } = useStateStore(
+ channelPaginatorsOrchestrator.state,
+ paginatorsSelector,
+ );
+
+ const options = useMemo[]>(() => {
+ const loaded = Array.from(
+ new Map(
+ paginators.flatMap((paginator) => paginator.items ?? []).map((c) => [c.cid, c]),
+ ).values(),
+ ).map((c) => ({ label: channelDisplayName(c), value: c.cid }));
+ return loaded.some((option) => option.value === channel.cid)
+ ? loaded
+ : [{ label: channelDisplayName(channel), value: channel.cid }, ...loaded];
+ }, [paginators, channel]);
+
+ return (
+ setSingleChannel(cid)}
+ options={options}
+ searchPlaceholder='Search channels'
+ value={channel.cid}
+ />
+ );
+};
+
+/**
+ * The single-channel "customer support" scenario, rendered in a floating, draggable modal (the
+ * same `DraggableDialog` shell the WS-event trigger uses) over the full app — so you can see the
+ * full view behind it and drag the single channel around. It's just one `` with a message
+ * list + composer (no channel list, no ChatView/slots), verifying the Channel component stands on
+ * its own with only its built-in bootstrap loading/error indicators.
+ *
+ * There is no separate "layout mode": the modal is simply open whenever `layout.channelCid` is set
+ * (App renders it then). The title switches the channel; the close button clears `channelCid`.
+ * `closeOnClickOutside={false}` keeps a click on the app behind from dismissing (and re-opening)
+ * the window, which would otherwise reset its dragged position.
+ */
+export const SingleChannelModal = ({
+ channel,
+ referenceElement,
+}: {
+ channel: StreamChannel;
+ referenceElement: HTMLElement | null;
+}) => {
+ const { dialog, dialogManager } = useDialogOnNearestManager({
+ id: SINGLE_CHANNEL_DIALOG_ID,
+ });
+ const dialogIsOpen = useDialogIsOpen(SINGLE_CHANNEL_DIALOG_ID, dialogManager?.id);
+
+ // Mounted only while a channel is selected. Open the floating dialog whenever it isn't already
+ // open (the `!dialogIsOpen` guard is idempotent — safe to re-run, can't loop); close on unmount
+ // via a ref so it doesn't depend on the per-render `dialog` handle.
+ const dialogRef = useRef(dialog);
+ dialogRef.current = dialog;
+ useEffect(() => {
+ if (!dialogIsOpen) dialog.open();
+ }, [dialog, dialogIsOpen]);
+ useEffect(() => () => dialogRef.current.close(), []);
+
+ return (
+ setSingleChannel(undefined)}
+ promptClassName='app-single-channel-modal__prompt'
+ referenceElement={referenceElement}
+ shellClassName='app-single-channel-modal__shell'
+ title={}
+ >
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss
index 922a276e3d..8e9af3716a 100644
--- a/examples/vite/src/index.scss
+++ b/examples/vite/src/index.scss
@@ -12,6 +12,7 @@ layer(stream-app-overrides);
@import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides);
@import url('./AccessibilityNavigation/ReturnToSkipNavigation.scss')
layer(stream-app-overrides);
+@import url('./SingleChannel/SingleChannel.scss') layer(stream-app-overrides);
@import url('stream-chat-react/dist/css/emoji-picker.css') layer(stream-new-plugins);
@import url('stream-chat-react/dist/css/channel-detail.css') layer(stream-new-plugins);
@@ -39,7 +40,7 @@ body {
@layer stream-overrides {
.app-chat-layout {
--app-left-panel-width: 360px;
- --app-thread-panel-width: 360px;
+ --app-secondary-panel-width: 360px;
display: flex;
flex: 1 1 auto;
@@ -74,21 +75,8 @@ body {
.str-chat__channel-list,
body.app-chat-resizing-sidebar[data-app-sidebar-resize-state='expanded']
.str-chat__thread-list-container,
- body.app-chat-resizing-thread .app-chat-resize-handle--thread,
- body.app-chat-resizing-thread .app-chat-thread-panel,
- body.app-chat-resizing-thread
- .str-chat__chat-view__channels
- .str-chat__container
- > .str-chat__dropzone-root--thread,
- body.app-chat-resizing-thread
- .str-chat__chat-view__channels
- .str-chat__container
- > .str-chat__thread-container,
- body.app-chat-resizing-thread
- .str-chat__chat-view__channels
- .str-chat__container
- > .str-chat__dropzone-root--thread
- .str-chat__thread-container {
+ body.app-chat-resizing-secondary .app-chat-resize-handle--secondary,
+ body.app-chat-resizing-secondary .app-chat-secondary-panel {
transition: none !important;
}
@@ -121,10 +109,14 @@ body {
min-width: 0;
height: 100%;
position: relative;
- z-index: 1;
+ z-index: 2;
}
- .app-chat-view__channels-layout > .str-chat__channel,
+ /* The channels view's content row (`.app-chat-view__channel-content`) is the layout's main
+ flex child; its own columns — the primary channel (`.app-chat-view__channel-main`) and the
+ secondary panel (`.app-chat-secondary-panel`) — are sized by the rules further down. Each
+ column element is now a (or the thread dropzone) carrying that column class, so
+ the channel and thread are siblings rather than one nested inside the other. */
.app-chat-view__threads-main {
display: flex;
flex: 1 1 auto;
@@ -132,6 +124,14 @@ body {
height: 100%;
}
+ /* Each column (main channel / 2nd channel) wraps a ; let its root fill the column
+ so the channel’s own message panel stretches edge to edge. */
+ .app-chat-view__channel-main > .str-chat__channel,
+ .app-chat-secondary-panel > .str-chat__channel {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
.app-chat-view__channel-content,
.app-chat-view__channel-main {
display: flex;
@@ -148,12 +148,43 @@ body {
min-height: 0;
}
- .app-chat-view__threads-main > * {
+ /* Threads view mirrors the channels view: the primary thread (and the empty-state
+ placeholder) fill the main column; an optional 2nd thread sits in the resizable secondary
+ column beside it, behind a resize handle. Only the primary/placeholder grow — the handle
+ and secondary panel keep their own widths. */
+ .app-chat-view__threads-main > .str-chat__dropzone-root--thread,
+ .app-chat-view__threads-main > .str-chat__thread-container {
flex: 1 1 auto;
min-width: 0;
height: 100%;
}
+ .app-chat-view__threads-main
+ > .app-chat-resize-handle--secondary
+ + .app-chat-secondary-panel.app-chat-secondary-panel--open {
+ flex: 0 0 max(var(--app-secondary-panel-width), 360px);
+ width: max(var(--app-secondary-panel-width), 360px);
+ max-width: max(var(--app-secondary-panel-width), 360px);
+ min-width: 360px;
+ }
+
+ @media (max-width: 767px) {
+ /* The resize handle is hidden globally on mobile; when a 2nd thread is open show it
+ full-width and collapse the primary (one panel at a time, like the channels view). */
+ .app-chat-view__threads-main--secondary-open > .str-chat__dropzone-root--thread {
+ display: none;
+ }
+
+ .app-chat-view__threads-main--secondary-open
+ > .app-chat-resize-handle--secondary
+ + .app-chat-secondary-panel.app-chat-secondary-panel--open {
+ flex: 1 1 auto;
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ }
+ }
+
.app-chat-resize-handle {
position: relative;
flex: 0 0 1px;
@@ -186,7 +217,7 @@ body {
background: transparent;
}
- .app-chat-resize-handle--thread {
+ .app-chat-resize-handle--secondary {
transition:
flex-basis var(--str-chat__channel-list-transition-duration, 180ms)
var(--str-chat__channel-list-transition-easing, ease),
@@ -198,7 +229,7 @@ body {
var(--str-chat__channel-list-transition-easing, ease);
}
- .app-chat-resize-handle--thread-hidden {
+ .app-chat-resize-handle--secondary-hidden {
flex: 0 0 0;
width: 0;
min-width: 0;
@@ -207,7 +238,7 @@ body {
pointer-events: none;
}
- .app-chat-thread-panel {
+ .app-chat-secondary-panel {
background: var(--str-chat__secondary-background-color);
display: flex;
flex-direction: column;
@@ -215,6 +246,60 @@ body {
min-width: 0;
}
+ /* Positioning context for the member-detail layer overlay (below). */
+ .app-chat-view__channel-content {
+ position: relative;
+ }
+
+ /* A `pushLayer` overlay (e.g. the member detail) covering the secondary column. It sits above
+ the base panel — which stays mounted underneath at its exact scroll position — pinned to the
+ same reserved width the secondary panel uses (`--app-secondary-panel-width`), so popping the
+ layer reveals the thread/2nd-channel untouched. Full-bleed on mobile, where the secondary
+ region is the whole content area. */
+ .app-chat-secondary-overlay {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-end: 0;
+ inline-size: max(var(--app-secondary-panel-width, 360px), 360px);
+ z-index: 3;
+ display: flex;
+ flex-direction: column;
+ /* Opaque, theme-aware surface (light: #fff, dark: dark) so the covered thread doesn't bleed
+ through. This theme exposes the `background-core-elevation-*` tokens, not the legacy
+ `--str-chat__*-background-color` ones. */
+ background: var(--str-chat__background-core-elevation-1, #ffffff);
+ }
+
+ .app-chat-secondary-overlay > .str-chat__channel-detail__channel-member-detail-view {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ }
+
+ /* The secondary slot's divider border lives on the SLOT region — the open base panel AND any
+ layer overlay covering it — so it shows consistently for a reply thread, a 2nd channel, and
+ stacked layers like the member profile. This is the same border the SDK Thread draws; we drop
+ the Thread's own copy below (search "border owned by the slot") so it isn't doubled. */
+ .app-chat-secondary-panel--open,
+ .app-chat-secondary-overlay {
+ border-inline-start: 1px solid var(--str-chat__border-core-default);
+ }
+
+ /* Border owned by the slot (above): drop the SDK Thread's own inline-start border wherever it
+ renders inside the secondary slot, at all widths, so the divider isn't doubled. App-level
+ override only — the SDK Thread styles are untouched. */
+ .app-chat-secondary-panel .str-chat__thread,
+ .app-chat-secondary-panel .str-chat__thread-container {
+ border-inline-start: none;
+ }
+
+ @media (max-width: 767px) {
+ .app-chat-secondary-overlay {
+ inset-inline: 0;
+ inline-size: auto;
+ }
+ }
+
.app-loading-screen__window {
width: 100%;
height: 100%;
@@ -235,13 +320,45 @@ body {
z-index: 4;
}
+ .app-channel-list-switcher {
+ display: flex;
+ padding-block: var(--str-chat__spacing-xxs);
+ padding-inline: var(--str-chat__spacing-xs);
+
+ .app-channel-list-switcher__trigger {
+ width: 100%;
+ justify-content: space-between;
+
+ .str-chat__button__content {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .app-channel-list-switcher__caret {
+ font-size: 0.75em;
+ line-height: 1;
+ }
+
+ &[aria-expanded='true'] .app-channel-list-switcher__caret {
+ transform: rotate(180deg);
+ }
+ }
+ }
+
+ /* The switcher menu is the SDK ContextMenu, portalled out of the switcher subtree, so it can't
+ be sized by ancestry. Match it to the trigger width via a CSS var set from JS (the trigger's
+ measured width). popper only sets position/inset on the menu, so `width` here sticks. */
+ .app-channel-list-switcher__menu {
+ width: var(--app-channel-list-switcher-menu-width);
+ }
+
@media (max-width: 767px) {
.app-chat-resize-handle {
display: none;
}
.app-chat-view__channel-content
- > .app-chat-thread-panel:not(.app-chat-thread-panel--open) {
+ > .app-chat-secondary-panel:not(.app-chat-secondary-panel--open) {
flex: 0 0 0;
width: 0;
min-width: 0;
@@ -254,7 +371,8 @@ body {
);
}
- .app-chat-view__channel-content > .app-chat-thread-panel.app-chat-thread-panel--open {
+ .app-chat-view__channel-content
+ > .app-chat-secondary-panel.app-chat-secondary-panel--open {
flex: 1 1 auto;
min-width: 0;
width: 100%;
@@ -434,15 +552,15 @@ body {
var(--str-chat__channel-list-transition-easing, ease);
}
- &.app-chat-view__channel-content--thread-open > .app-chat-view__channel-main {
+ &.app-chat-view__channel-content--secondary-open > .app-chat-view__channel-main {
/* With thread open, give the thread its reserved width first,
then let the main panel take whatever space is left. */
- flex-basis: max(0px, calc(100% - max(var(--app-thread-panel-width), 360px)));
- max-width: max(0px, calc(100% - max(var(--app-thread-panel-width), 360px)));
+ flex-basis: max(0px, calc(100% - max(var(--app-secondary-panel-width), 360px)));
+ max-width: max(0px, calc(100% - max(var(--app-secondary-panel-width), 360px)));
min-width: 360px;
}
- > .app-chat-resize-handle--thread + .app-chat-thread-panel {
+ > .app-chat-resize-handle--secondary + .app-chat-secondary-panel {
flex: 0 0 0;
width: 0;
min-width: 0;
@@ -468,11 +586,11 @@ body {
var(--str-chat__channel-list-transition-easing, ease);
}
- > .app-chat-resize-handle--thread
- + .app-chat-thread-panel.app-chat-thread-panel--open {
- flex: 0 0 max(var(--app-thread-panel-width), 360px);
- width: max(var(--app-thread-panel-width), 360px);
- max-width: max(var(--app-thread-panel-width), 360px);
+ > .app-chat-resize-handle--secondary
+ + .app-chat-secondary-panel.app-chat-secondary-panel--open {
+ flex: 0 0 max(var(--app-secondary-panel-width), 360px);
+ width: max(var(--app-secondary-panel-width), 360px);
+ max-width: max(var(--app-secondary-panel-width), 360px);
min-width: 360px;
margin-inline-start: auto;
opacity: 1;
@@ -482,16 +600,16 @@ body {
}
.app-chat-view__channel-content
- > .app-chat-resize-handle--thread
- + .app-chat-thread-panel
+ > .app-chat-resize-handle--secondary
+ + .app-chat-secondary-panel
.str-chat__dropzone-root--thread,
.app-chat-view__channel-content
- > .app-chat-resize-handle--thread
- + .app-chat-thread-panel
+ > .app-chat-resize-handle--secondary
+ + .app-chat-secondary-panel
.str-chat__thread-container,
.app-chat-view__channel-content
- > .app-chat-resize-handle--thread
- + .app-chat-thread-panel
+ > .app-chat-resize-handle--secondary
+ + .app-chat-secondary-panel
.str-chat__dropzone-root--thread
.str-chat__thread-container {
width: 100%;
@@ -499,18 +617,13 @@ body {
max-width: none;
}
- /* Hide thread's own start border when resize handle provides the separator */
- .app-chat-resize-handle--thread + .app-chat-thread-panel .str-chat__thread-container {
- --str-chat__thread-border-inline-start: none;
- }
-
/* If space gets tight while sidebar + thread are both open,
prioritize the thread and collapse the main panel. */
@container (max-width: 1148px) {
.app-chat-view__channels-layout:not(
.app-chat-view__channels-layout--sidebar-collapsed
)
- .app-chat-view__channel-content--thread-open
+ .app-chat-view__channel-content--secondary-open
> .app-chat-view__channel-main {
flex: 0 0 0;
width: 0;
@@ -526,8 +639,8 @@ body {
.app-chat-view__channels-layout:not(
.app-chat-view__channels-layout--sidebar-collapsed
)
- .app-chat-view__channel-content--thread-open
- > .app-chat-resize-handle--thread {
+ .app-chat-view__channel-content--secondary-open
+ > .app-chat-resize-handle--secondary {
flex: 0 0 0;
width: 0;
min-width: 0;
@@ -539,8 +652,8 @@ body {
.app-chat-view__channels-layout:not(
.app-chat-view__channels-layout--sidebar-collapsed
)
- .app-chat-view__channel-content--thread-open
- > .app-chat-thread-panel.app-chat-thread-panel--open {
+ .app-chat-view__channel-content--secondary-open
+ > .app-chat-secondary-panel.app-chat-secondary-panel--open {
flex: 1 1 auto;
width: auto;
min-width: 360px;
@@ -551,14 +664,14 @@ body {
}
@container (max-width: 860px) {
- .app-chat-resize-handle--thread {
+ .app-chat-resize-handle--secondary {
display: none;
}
.str-chat__chat-view__channels
- .str-chat__container:has(.app-chat-resize-handle--thread)
- > .app-chat-resize-handle--thread
- + .app-chat-thread-panel.app-chat-thread-panel--open,
+ .app-chat-view__channel-content
+ > .app-chat-resize-handle--secondary
+ + .app-chat-secondary-panel.app-chat-secondary-panel--open,
.str-chat__thread-container {
flex: 1 1 auto;
width: 100%;
@@ -596,7 +709,7 @@ body {
var(--str-chat__chat-view-selector-transition-easing, ease),
visibility 0s linear
var(--str-chat__chat-view-selector-transition-duration, 180ms);
- z-index: 1;
+ z-index: 2;
}
.app-chat-view__channels-layout:not(
@@ -666,7 +779,7 @@ body {
display: none;
}
- .app-chat-view__channel-content--thread-open .app-chat-view__channel-main {
+ .app-chat-view__channel-content--secondary-open .app-chat-view__channel-main {
flex: 0 0 0;
width: 0;
min-width: 0;
@@ -678,7 +791,7 @@ body {
);
}
- .app-chat-view__channel-content--thread-open > .app-chat-resize-handle--thread {
+ .app-chat-view__channel-content--secondary-open > .app-chat-resize-handle--secondary {
flex: 0 0 0;
width: 0;
min-width: 0;
@@ -687,8 +800,8 @@ body {
pointer-events: none;
}
- .app-chat-view__channel-content--thread-open
- > .app-chat-thread-panel.app-chat-thread-panel--open {
+ .app-chat-view__channel-content--secondary-open
+ > .app-chat-secondary-panel.app-chat-secondary-panel--open {
flex: 1 1 auto;
min-width: 0;
width: 100%;
diff --git a/package.json b/package.json
index 8a518a855e..d3bf7ac1ce 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,12 @@
"require": "./dist/cjs/mp3-encoder.js",
"default": "./dist/cjs/mp3-encoder.js"
},
+ "./slot-geometry": {
+ "types": "./dist/types/plugins/SlotGeometry/index.d.ts",
+ "import": "./dist/es/slot-geometry.mjs",
+ "require": "./dist/cjs/slot-geometry.js",
+ "default": "./dist/cjs/slot-geometry.js"
+ },
"./dist/css/*": {
"default": "./dist/css/*"
},
@@ -57,6 +63,9 @@
],
"mp3-encoder": [
"./dist/types/plugins/encoders/mp3.d.ts"
+ ],
+ "slot-geometry": [
+ "./dist/types/plugins/SlotGeometry/index.d.ts"
]
}
},
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..74025bbb8b
--- /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 `entityInferrers`, 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..748c92e214 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 '../../context/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 95dcb2332a..7cdcea7fe5 100644
--- a/src/components/Attachment/AttachmentContainer.tsx
+++ b/src/components/Attachment/AttachmentContainer.tsx
@@ -43,11 +43,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 '../../context/AttachmentContext';
export type AttachmentContainerProps = {
attachment: Attachment | GalleryAttachment | SharedLocationResponse;
@@ -220,7 +222,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/Audio.tsx b/src/components/Attachment/Audio.tsx
index 5ece01381f..f4b1152cf2 100644
--- a/src/components/Attachment/Audio.tsx
+++ b/src/components/Attachment/Audio.tsx
@@ -13,6 +13,7 @@ import type { AudioPlayer } from '../AudioPlayback/AudioPlayer';
import { PlayButton } from '../Button/PlayButton';
import { FileIcon } from '../FileIcon';
import { DurationDisplay, ProgressBar } from '../AudioPlayback';
+import { useThreadContext } from '../Threads';
type AudioAttachmentUIProps = {
audioPlayer: AudioPlayer;
@@ -100,7 +101,8 @@ export const Audio = (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({
durationSeconds: duration,
@@ -108,7 +110,7 @@ export const Audio = (props: AudioProps) => {
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 e997bb2592..dd43296baf 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 } from './icons';
import { IconLocation } from '../Icons';
import { Button } from '../Button';
@@ -21,7 +21,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 d50acefb31..d2d0314c40 100644
--- a/src/components/Attachment/Giphy.tsx
+++ b/src/components/Attachment/Giphy.tsx
@@ -2,14 +2,13 @@ import type { Attachment } from 'stream-chat';
import { BaseImage as DefaultBaseImage } from '../BaseImage';
import { toGalleryItemDescriptors } from '../Gallery';
import clsx from 'clsx';
-import {
- useChannelStateContext,
- useComponentContext,
- useTranslationContext,
-} from '../../context';
+import { useComponentContext, useTranslationContext } from '../../context';
import { IconGiphy } from '../Icons';
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react';
-import type { ImageAttachmentConfiguration } from '../../types/types';
+import {
+ type ImageAttachmentConfiguration,
+ useAttachmentContext,
+} from '../../context/AttachmentContext';
export type GiphyAttachmentProps = {
attachment: Attachment;
@@ -17,7 +16,7 @@ export type GiphyAttachmentProps = {
export const Giphy = ({ attachment }: GiphyAttachmentProps) => {
const { giphyVersion: giphyVersionName, imageAttachmentSizeHandler } =
- useChannelStateContext();
+ useAttachmentContext();
const { BaseImage = DefaultBaseImage } = useComponentContext();
const { t } = useTranslationContext();
const usesDefaultBaseImage = BaseImage === DefaultBaseImage;
diff --git a/src/components/Attachment/LinkPreview/Card.tsx b/src/components/Attachment/LinkPreview/Card.tsx
index 365a8088d0..a660ba6a32 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 '../../BaseImage';
import { SafeAnchor } from '../../SafeAnchor';
-import { useChannelStateContext } from '../../../context/ChannelStateContext';
+import { useAttachmentContext } from '../../../context/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 538edbe448..ce90990af4 100644
--- a/src/components/Attachment/LinkPreview/CardAudio.tsx
+++ b/src/components/Attachment/LinkPreview/CardAudio.tsx
@@ -7,6 +7,7 @@ import React from 'react';
import { IconLink } 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) {
@@ -55,13 +56,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..f4d7dde16e 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 '../../context/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 d885023b83..0dea3f6c78 100644
--- a/src/components/Attachment/VoiceRecording.tsx
+++ b/src/components/Attachment/VoiceRecording.tsx
@@ -18,6 +18,7 @@ import {
import { useAudioPlayer } from '../AudioPlayback/WithAudioPlayback';
import { useStateStore } from '../../store';
import { PlayButton } from '../Button';
+import { useThreadContext } from '../Threads';
const rootClassName = 'str-chat__message-attachment__voice-recording-widget';
@@ -121,7 +122,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,
@@ -130,7 +132,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__/AttachmentScopedConfig.test.js b/src/components/Attachment/__tests__/AttachmentScopedConfig.test.js
new file mode 100644
index 0000000000..6501294cca
--- /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 '../../../context/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 = vi.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 = vi.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/Channel/Channel.tsx b/src/components/Channel/Channel.tsx
index e070a39eaf..4817f500e6 100644
--- a/src/components/Channel/Channel.tsx
+++ b/src/components/Channel/Channel.tsx
@@ -1,56 +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 {
- ChannelAPIResponse,
ChannelMemberResponse,
ChannelQueryOptions,
- ChannelState,
DeleteMessageOptions,
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 { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator';
-import { useNotificationApi } from '../Notifications';
-
-import type { ChannelActionContextValue, MarkReadWrapperOptions } from '../../context';
-import {
- ChannelActionProvider,
- ChannelStateProvider,
- TypingProvider,
+ ChannelInstanceProvider,
useChatContext,
useComponentContext,
useTranslationContext,
@@ -59,33 +30,15 @@ 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 {
- adaptMessageSendErrorToErrorFromResponse,
- findInMsgSetByDate,
- findInMsgSetById,
-} 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 '../Search/hooks';
import { WithAudioPlayback } from '../AudioPlayback';
@@ -111,8 +64,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,
@@ -125,12 +78,8 @@ export type ChannelProps = {
updatedMessage: LocalMessage | MessageResponse,
options?: UpdateMessageOptions,
) => ReturnType;
- /** Custom UI component to be shown if no active channel is set, defaults to the message empty state indicator. Pass `null` to suppress rendering. */
- EmptyPlaceholder?: React.ReactElement | null;
- /** 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;
+ /** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */
+ EmptyPlaceholder?: React.ReactElement;
/**
* 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 +88,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 = ({
@@ -156,7 +95,7 @@ const ChannelContainer = ({
className: additionalClassName,
...props
}: PropsWithChildren>) => {
- const { customClasses, theme } = useChatContext('Channel');
+ const { customClasses, theme } = useChatContext();
const { channelClass, chatClass } = useChannelContainerClasses({
customClasses,
});
@@ -168,44 +107,27 @@ const ChannelContainer = ({
);
};
-const UnMemoizedChannel = (props: PropsWithChildren) => {
- const { channel: propsChannel, EmptyPlaceholder } = props;
- const {
- EmptyStateIndicator = DefaultEmptyStateIndicator,
- LoadingErrorIndicator,
- LoadingIndicator = DefaultLoadingIndicator,
- } = useComponentContext('Channel');
-
- const { channel: contextChannel, channelsQueryState } = useChatContext('Channel');
-
- const channel = propsChannel || contextChannel;
- const emptyPlaceholder =
- 'EmptyPlaceholder' in props
- ? EmptyPlaceholder
- : EmptyStateIndicator && ;
-
- if (channelsQueryState.queryInProgress === 'reload' && LoadingIndicator) {
- return (
-
-
-
- );
- }
-
- if (channelsQueryState.error && !channel && LoadingErrorIndicator) {
- return (
-
-
-
- );
- }
-
- if (channelsQueryState.error && !channel) {
- return ;
- }
+/**
+ * A wrapper component that provides channel data and renders children.
+ * The Channel component provides the following contexts:
+ * - [ComponentContext](https://getstream.io/chat/docs/sdk/react/contexts/component_context/)
+ * - [TypingContext](https://getstream.io/chat/docs/sdk/react/contexts/typing_context/)
+ *
+ * Not wrapped in `React.memo`: `Channel` always receives `children` (a fresh element every
+ * parent render), so the default shallow comparison never matches and memo could never skip a
+ * render. The heavy descendants (`MessageList`, etc.) memoize themselves.
+ */
+export const Channel = (props: PropsWithChildren) => {
+ const { channel: propsChannel, EmptyPlaceholder = null } = props;
+ // The channel is supplied via the `channel` prop (fed by a channel slot / the app's
+ // slot-bound ). NOTE: master's giphyVersion/imageAttachmentSizeHandler/
+ // videoAttachmentSizeHandler props + AttachmentContextProvider (custom attachment sizing)
+ // are NOT provided by this PR-base Channel — useAttachmentContext falls back to defaults.
+ // Re-graft the AttachmentContextProvider if custom sizing is needed.
+ const channel = propsChannel;
if (!channel?.cid) {
- return {emptyPlaceholder};
+ return {EmptyPlaceholder};
}
return ;
@@ -231,133 +153,73 @@ 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, latestMessageDatesByChannels, searchController } =
useChatContext('Channel');
- const { addNotification } = useNotificationApi();
const { t } = useTranslationContext('Channel');
- const chatContainerClass = getChatContainerClass(customClasses?.chatContainer);
const windowsEmojiClass = useImageFlagEmojisOnWindowsClass();
- const thread = useThreadContext();
-
- const [channelConfig, setChannelConfig] = useState(channel.getConfig());
-
- 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 = useMemo(
- () =>
- throttle(() => dispatch({ channel, type: 'copyStateFromChannelOnEvent' }), 500, {
- leading: true,
- trailing: true,
- }),
- [channel],
- );
-
- 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;
- }
+ const markChannelRead = useCallback(
+ async ({
+ updateChannelUiUnreadState = true,
+ }: { updateChannelUiUnreadState?: boolean } = {}) => {
+ if (channel.disconnected || !channelConfig?.read_events) {
+ return;
+ }
- lastRead.current = new Date();
+ lastRead.current = new Date();
- 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,
- });
- }
- }
+ 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)
@@ -366,10 +228,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;
}
@@ -413,42 +271,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
@@ -469,38 +321,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);
@@ -510,8 +359,8 @@ const ChannelInner = (
channel.on(handleEvent);
}
})();
-
return () => {
+ isMounted = false;
if (errored || !done) return;
channel?.off(handleEvent);
client.off('connection.changed', handleEvent);
@@ -522,653 +371,45 @@ const ChannelInner = (
}, [
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 */
- const notifyJumpToFirstUnreadError = useCallback(() => {
- addNotification({
- context: { feature: 'jumpToFirstUnread' },
- emitter: 'Channel',
- message: t('Failed to jump to the first unread message'),
- severity: 'error',
- targetPanels: ['channel'],
- type: 'channel:jumpToFirstUnread:failed',
+ void channel.messagePaginator.jumpToMessage(jumpToMessageFromSearch.id, {
+ focusReason: 'jump-to-message',
+ focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
});
- }, [addNotification, t]);
-
- // 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 finishLoadMore = useCallback(
- (hasMore: boolean, messages: ChannelState['messages']) => {
- if (!isMounted.current) return;
- dispatch({ hasMore, messages, type: 'loadMoreFinished' });
- },
- [isMounted],
- );
-
- const loadMore = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => {
- if (
- channel.disconnected ||
- !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.cancel();
- finishLoadMore(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;
+ if (clearSearchFocusedMessageTimeoutId.current) {
+ clearTimeout(clearSearchFocusedMessageTimeoutId.current);
}
-
- dispatch({
- hasMoreNewer: channel.state.messagePagination.hasNext,
- messages: channel.state.messages,
- type: 'loadMoreNewerFinished',
- });
- return queryResponse.messages.length;
- };
-
- const jumpToMessage: ChannelActionContextValue['jumpToMessage'] = useCallback(
- async (
- messageId,
- messageLimit = DEFAULT_JUMP_TO_PAGE_SIZE,
- highlightDuration = DEFAULT_HIGHLIGHT_DURATION,
- ) => {
- // Keep quoted-message jumps out of the older-page pagination path.
- dispatch({
- loadingMoreForJumpToChannelMessage: true,
- type: 'setLoadingMoreForJumpToChannelMessage',
- });
- loadMoreFinished.cancel();
- try {
- await channel.state.loadMessageIntoState(messageId, undefined, messageLimit);
-
- handleHighlightedMessageChange({
- highlightDuration,
- highlightedMessageId: messageId,
- });
- } catch (error) {
- dispatch({
- loadingMoreForJumpToChannelMessage: false,
- type: 'setLoadingMoreForJumpToChannelMessage',
- });
- throw error;
+ clearSearchFocusedMessageTimeoutId.current = setTimeout(() => {
+ if (searchController._internalState.getLatestValue().focusedMessage) {
+ searchController._internalState.partialNext({ focusedMessage: undefined });
}
- },
- [channel, handleHighlightedMessageChange, loadMoreFinished],
- );
-
- const jumpToLatestMessage: ChannelActionContextValue['jumpToLatestMessage'] =
- useCallback(async () => {
- loadMoreFinished.cancel();
- await channel.state.loadMessageIntoState('latest');
- dispatch({
- hasMore: channel.state.messagePagination.hasPrev,
- hasMoreNewer: channel.state.messagePagination.hasNext,
- messages: channel.state.messages,
- type: 'jumpToLatestMessageFinished',
- });
- }, [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) {
- notifyJumpToFirstUnreadError();
- finishLoadMore(
- channel.state.messagePagination.hasPrev,
- channel.state.messages,
- );
- return;
- }
-
- const firstMessageWithCreationDate = messages.find((msg) => msg.created_at);
- if (!firstMessageWithCreationDate) {
- notifyJumpToFirstUnreadError();
- finishLoadMore(
- 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;
- }
- finishLoadMore(
- channel.state.messagePagination.hasPrev,
- channel.state.messages,
- );
- }
- }
-
- if (!firstUnreadMessageId && !lastReadMessageId) {
- notifyJumpToFirstUnreadError();
- 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;
- finishLoadMore(
- channel.state.messagePagination.hasPrev,
- channel.state.messages,
- );
- firstUnreadMessageId =
- firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1]?.id;
- } catch (e) {
- notifyJumpToFirstUnreadError();
- finishLoadMore(
- channel.state.messagePagination.hasPrev,
- channel.state.messages,
- );
- return;
- }
- }
-
- if (!firstUnreadMessageId) {
- notifyJumpToFirstUnreadError();
- return;
- }
- if (!channelUnreadUiState.first_unread_message_id)
- _setChannelUnreadUiState({
- ...channelUnreadUiState,
- first_unread_message_id: firstUnreadMessageId,
- last_read_message_id: lastReadMessageId,
- });
- handleHighlightedMessageChange({
- highlightDuration,
- highlightedMessageId: firstUnreadMessageId,
- });
- },
- [
- channel,
- finishLoadMore,
- handleHighlightedMessageChange,
- notifyJumpToFirstUnreadError,
- 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) {
- const parsedError = adaptMessageSendErrorToErrorFromResponse(error);
-
- // 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',
- },
- });
- }
- }
- };
-
- 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(
- () => ({
- 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 = null;
+ }, DEFAULT_HIGHLIGHT_DURATION);
+ }, [
+ channel.messagePaginator,
+ jumpToMessageFromSearch,
+ searchController._internalState,
+ ]);
- if (state.error) {
+ if (isBootstrapping && LoadingIndicator) {
return (
-
+
);
}
- if (state.loading) {
+ if (bootstrapError && LoadingErrorIndicator) {
return (
-
+
);
}
@@ -1183,25 +424,16 @@ const ChannelInner = (
return (
-
-
-
-
- {children}
-
-
-
-
+
+ {/* `.str-chat__channel` (rendered by ChannelContainer above) is itself the channel's
+ main content column — a flex column that fills its parent. Children (header,
+ message list, composer) render directly inside it; there is no separate
+ `.str-chat__container` / `.str-chat__main-panel` wrapper anymore (the classic
+ side-by-side Thread is a slot now, not a nested child). */}
+
+ {children}
+
+
);
};
-
-/**
- * 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/)
- */
-export const Channel = React.memo(UnMemoizedChannel) as typeof UnMemoizedChannel;
diff --git a/src/components/Channel/ChannelSlot.tsx b/src/components/Channel/ChannelSlot.tsx
new file mode 100644
index 0000000000..bad4b2734a
--- /dev/null
+++ b/src/components/Channel/ChannelSlot.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+
+import { 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 is bound to `slot`. */
+ fallback?: ReactNode;
+ /**
+ * The layout slot this adapter renders. Always pass an explicit `slot` — a
+ * generic list/workspace must be precise about which slot it displays (multiple
+ * channels can be open side by side). Omitting it falls back to the first
+ * channel slot and is only meaningful for a single-channel workspace.
+ */
+ slot?: SlotName;
+ }
+>;
+
+/**
+ * ChatView-aware channel adapter: renders the channel bound to `slot`.
+ *
+ * It only *displays* — it never adopts/moves a channel from another slot. Which
+ * channel goes into which slot is decided by navigation (`open`). If your app
+ * resolves channel instances directly from layout bindings (e.g. via custom
+ * `slotRenderers`), render `` directly and skip this.
+ */
+export const ChannelSlot = ({
+ children,
+ fallback = null,
+ slot,
+ ...channelProps
+}: ChannelSlotProps) => {
+ const channel = useSlotChannel({ slot });
+
+ if (!channel) return <>{fallback}>;
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/Channel/channelState.ts b/src/components/Channel/channelState.ts
deleted file mode 100644
index 67e9692615..0000000000
--- a/src/components/Channel/channelState.ts
+++ /dev/null
@@ -1,312 +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';
- }
- | {
- hasMore: boolean;
- hasMoreNewer: boolean;
- messages: LocalMessage[];
- type: 'jumpToLatestMessageFinished';
- }
- | {
- 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';
- }
- | {
- loadingMoreForJumpToChannelMessage: boolean;
- type: 'setLoadingMoreForJumpToChannelMessage';
- }
- | {
- loadingMoreNewer: boolean;
- type: 'setLoadingMoreNewer';
- }
- | {
- message: LocalMessage;
- type: 'setThread';
- }
- | {
- channel: Channel;
- type: 'setTyping';
- }
- | {
- type: 'startLoadingThread';
- }
- | {
- channel: Channel;
- message: MessageResponse;
- type: 'updateThreadOnEvent';
- };
-
-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 'jumpToLatestMessageFinished': {
- const { hasMore, hasMoreNewer, messages } = action;
- return {
- ...state,
- hasMore,
- hasMoreNewer,
- highlightedMessageId: undefined,
- loading: false,
- messages,
- suppressAutoscroll: false,
- };
- }
-
- case 'jumpToMessageFinished': {
- return {
- ...state,
- hasMore: action.channel.state.messagePagination.hasPrev,
- hasMoreNewer: action.channel.state.messagePagination.hasNext,
- highlightedMessageId: action.highlightedMessageId,
- loadingMore: false,
- loadingMoreForJumpToChannelMessage: false,
- messages: action.channel.state.messages,
- suppressAutoscroll: false,
- };
- }
-
- 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 'setLoadingMoreForJumpToChannelMessage': {
- const { loadingMoreForJumpToChannelMessage } = action;
- return {
- ...state,
- loadingMoreForJumpToChannelMessage,
- suppressAutoscroll: loadingMoreForJumpToChannelMessage,
- };
- }
-
- 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,
- loadingMoreForJumpToChannelMessage: 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..0c64661f1b
--- /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: vi.fn().mockResolvedValue(null),
+ sendMessage: vi.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 = vi
+ .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 = vi
+ .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 = vi.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 07232a14c2..0000000000
--- a/src/components/Channel/hooks/useCreateChannelStateContext.ts
+++ /dev/null
@@ -1,172 +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,
- loadingMoreForJumpToChannelMessage,
- members,
- messages = [],
- mutes,
- notifications,
- pinnedMessages,
- read = {},
- shouldGenerateVideoThumbnail,
- skipMessageDataMemoization,
- suppressAutoscroll,
- thread,
- threadHasMore,
- threadLoadingMore,
- threadMessages = [],
- videoAttachmentSizeHandler,
- watcher_count,
- watcherCount,
- watchers,
- } = value;
-
- const channelId = channel.cid;
- // `channel.lastRead()` reaches `channel.getClient()`, which throws once the
- // client has been disconnected. Guard against it so a disconnect while the
- // channel is mounted does not crash the render (#2393).
- const lastRead =
- channel.initialized && !channel.disconnected && 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,
- loadingMoreForJumpToChannelMessage,
- 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,
- loadingMoreForJumpToChannelMessage,
- 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 12a24b9e66..0000000000
--- a/src/components/Channel/hooks/useMentionsHandlers.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import type React from 'react';
-import { useCallback } from 'react';
-import type { LocalMessage, UserResponse } from 'stream-chat';
-
-export type OnMentionAction = (
- event: React.BaseSyntheticEvent,
- /**
- * @deprecated Use the third `message` argument to access mention metadata instead.
- * FIXME: Remove this argument in the next major release.
- */
- user?: UserResponse,
- message?: LocalMessage,
-) => void;
-
-export const useMentionsHandlers = (
- onMentionsHover?: OnMentionAction,
- onMentionsClick?: OnMentionAction,
-) =>
- useCallback(
- (
- event: React.BaseSyntheticEvent,
- mentioned_users: UserResponse[],
- message?: LocalMessage,
- ) => {
- 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'
- ) {
- if (message) {
- onMentionsHover(event, user, message);
- } else {
- onMentionsHover(event, user);
- }
- }
-
- if (
- onMentionsClick &&
- event.type === 'click' &&
- typeof onMentionsClick === 'function'
- ) {
- if (message) {
- onMentionsClick(event, user, message);
- } else {
- 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/styling/Channel.scss b/src/components/Channel/styling/Channel.scss
index c615ca7121..2347100992 100644
--- a/src/components/Channel/styling/Channel.scss
+++ b/src/components/Channel/styling/Channel.scss
@@ -1,43 +1,23 @@
+/* `.str-chat__channel` is the channel's main content column: a flex column that fills its
+ parent and stacks the header / message list / composer. It used to wrap a `.str-chat__container`
+ (flex row for a side-by-side Thread) and a `.str-chat__main-panel`; the thread is a slot now,
+ so both wrappers are gone and their layout lives here directly. */
.str-chat__channel {
+ position: relative;
height: 100%;
display: flex;
+ flex: 1 1 auto;
flex-direction: column;
+ width: 100%;
+ min-width: 0;
min-height: 0;
- position: relative;
- .str-chat__container {
- height: 100%;
+ /* A drag-and-drop wrapper placed directly inside the channel becomes the content column. */
+ > .str-chat__dropzone-root {
display: flex;
-
- .str-chat__main-panel {
- position: relative;
- height: 100%;
- display: flex;
- flex-direction: column;
- width: 100%;
- min-width: 0;
- }
-
- /* Mobile (<768px): when thread is open, show only thread (channel panel collapsed). */
- @media (max-width: 767px) {
- &:has(.str-chat__thread-container) {
- > .str-chat__main-panel,
- > .str-chat__dropzone-root:not(.str-chat__dropzone-root--thread) {
- flex: 0 0 0;
- width: 0;
- min-width: 0;
- max-width: 0;
- overflow: hidden;
- }
-
- > .str-chat__thread-container,
- > .str-chat__dropzone-root--thread {
- flex: 1 1 auto;
- min-width: 0;
- width: 100%;
- }
- }
- }
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
}
}
diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx
index cdb04b64d4..a78f680d4d 100644
--- a/src/components/ChannelHeader/ChannelHeader.tsx
+++ b/src/components/ChannelHeader/ChannelHeader.tsx
@@ -4,15 +4,22 @@ import { type ChannelAvatarProps, ChannelAvatar as DefaultAvatar } from '../Avat
import { TypingIndicatorHeader } from '../TypingIndicator/TypingIndicatorHeader';
import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus';
import { useChannelPreviewInfo } from '../ChannelListItem/hooks/useChannelPreviewInfo';
-import { useChannelStateContext } from '../../context/ChannelStateContext';
-import { useChatContext } from '../../context/ChatContext';
-import { useComponentContext } from '../../context/ComponentContext';
-import { useTypingContext } from '../../context/TypingContext';
+import { useChannel, useChatContext, useComponentContext } from '../../context';
+import { useChannelConfig } from '../Channel/hooks/useChannelConfig';
+import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
+import { useStateStore } from '../../store';
+
+import type { TextComposerState } from 'stream-chat';
+
+const textComposerTypingSelector = ({ typing }: TextComposerState) => ({ typing });
const ChannelHeaderSubtitle = () => {
- const { channelConfig } = useChannelStateContext('ChannelHeaderSubtitle');
+ const channel = useChannel();
+ const channelConfig = useChannelConfig({ cid: channel.cid });
const { client } = useChatContext('ChannelHeaderSubtitle');
- const { typing = {} } = useTypingContext('ChannelHeaderSubtitle');
+ const messageComposer = useMessageComposerController();
+ const { typing = {} } =
+ useStateStore(messageComposer.textComposer?.state, textComposerTypingSelector) ?? {};
const onlineStatusText = useChannelHeaderOnlineStatus();
const typingInChannel = Object.values(typing).filter(
({ parent_id, user }) => user?.id !== client.user?.id && !parent_id,
@@ -48,7 +55,7 @@ export type ChannelHeaderProps = {
export const ChannelHeader = (props: ChannelHeaderProps) => {
const { Avatar = DefaultAvatar, image: overrideImage, title: overrideTitle } = props;
- const { channel } = useChannelStateContext();
+ const channel = useChannel();
const { HeaderStartContent } = useComponentContext();
const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({
channel,
diff --git a/src/components/ChannelHeader/hooks/__tests__/useChannelHasMembersOnline.test.ts b/src/components/ChannelHeader/hooks/__tests__/useChannelHasMembersOnline.test.ts
deleted file mode 100644
index 71e0600aa4..0000000000
--- a/src/components/ChannelHeader/hooks/__tests__/useChannelHasMembersOnline.test.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { act, renderHook } from '@testing-library/react';
-import { fromPartial } from '@total-typescript/shoehorn';
-import type { Channel } from 'stream-chat';
-
-import { useChannelStateContext } from '../../../../context/ChannelStateContext';
-import { useChatContext } from '../../../../context/ChatContext';
-import { useChannelHasMembersOnline } from '../useChannelHasMembersOnline';
-
-vi.mock('../../../../context/ChannelStateContext');
-vi.mock('../../../../context/ChatContext');
-
-type WatchingEvent = { user?: { id?: string } };
-type EventHandler = (event: WatchingEvent) => void;
-
-const createChannel = (watchers: Record = {}) => {
- const handlers: Record = {};
-
- const channel = fromPartial({
- on: vi.fn((event: string, handler: EventHandler) => {
- (handlers[event] = handlers[event] ?? []).push(handler);
- return { unsubscribe: vi.fn() };
- }),
- state: { watchers },
- });
-
- const emit = (event: string, payload: WatchingEvent) =>
- act(() => handlers[event]?.forEach((handler) => handler(payload)));
-
- return { channel, emit };
-};
-
-const renderForChannel = (channel: Channel) => {
- vi.mocked(useChannelStateContext).mockReturnValue({
- channel,
- } as ReturnType);
-
- return renderHook(() => useChannelHasMembersOnline());
-};
-
-describe('useChannelHasMembersOnline', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(useChatContext).mockReturnValue({
- client: { user: { id: 'me' } },
- } as ReturnType);
- });
-
- it('does not count the current user already in the watcher set', () => {
- const { channel } = createChannel({ me: { id: 'me' } });
-
- const { result } = renderForChannel(channel);
-
- expect(result.current).toBe(false);
- });
-
- it('returns true when another user is watching', () => {
- const { channel } = createChannel({ me: { id: 'me' }, other: { id: 'other' } });
-
- const { result } = renderForChannel(channel);
-
- expect(result.current).toBe(true);
- });
-
- it('ignores the current user starting to watch', () => {
- const { channel, emit } = createChannel();
-
- const { result } = renderForChannel(channel);
- expect(result.current).toBe(false);
-
- emit('user.watching.start', { user: { id: 'me' } });
-
- expect(result.current).toBe(false);
- });
-
- it('counts another user starting to watch and stops on watching.stop', () => {
- const { channel, emit } = createChannel();
-
- const { result } = renderForChannel(channel);
- expect(result.current).toBe(false);
-
- emit('user.watching.start', { user: { id: 'other' } });
- expect(result.current).toBe(true);
-
- emit('user.watching.stop', { user: { id: 'other' } });
- expect(result.current).toBe(false);
- });
-});
diff --git a/src/components/ChannelHeader/hooks/__tests__/useChannelHasMembersOnline.test.tsx b/src/components/ChannelHeader/hooks/__tests__/useChannelHasMembersOnline.test.tsx
new file mode 100644
index 0000000000..0801bc853b
--- /dev/null
+++ b/src/components/ChannelHeader/hooks/__tests__/useChannelHasMembersOnline.test.tsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+import { StateStore } from 'stream-chat';
+import type { Channel, StreamChat, UserResponse, WatcherState } from 'stream-chat';
+
+import { ChannelInstanceProvider, ChatProvider } from '../../../../context';
+import { mockChatContext } from '../../../../mock-builders';
+import { useChannelHasMembersOnline } from '../useChannelHasMembersOnline';
+
+// MERGE-RECONCILE (test migration): PR #2909 reworked useChannelHasMembersOnline from manual
+// `channel.on('user.watching.start/stop')` tracking (via the deleted ChannelStateContext) to a
+// reactive `channel.state.watcherStore` subscription (through useChannel). The test provides a
+// channel with a real StateStore watcherStore and asserts the "exclude the current user" logic
+// and reactivity to store changes.
+
+const makeChannel = (watchers: Record = {}) => {
+ const watcherStore = new StateStore({
+ watcherCount: Object.keys(watchers).length,
+ watchers,
+ });
+ const channel = fromPartial({ state: { watcherStore } });
+ return { channel, watcherStore };
+};
+
+const setWatchers = (
+ watcherStore: StateStore,
+ watchers: Record,
+) =>
+ act(() =>
+ watcherStore.partialNext({
+ watcherCount: Object.keys(watchers).length,
+ watchers,
+ }),
+ );
+
+const renderForChannel = (
+ channel: Channel,
+ params?: Parameters[0],
+) => {
+ const client = fromPartial({ user: { id: 'me' } });
+ const wrapper = ({ children }: React.PropsWithChildren) => (
+
+ {children}
+
+ );
+ return renderHook(() => useChannelHasMembersOnline(params), { wrapper });
+};
+
+describe('useChannelHasMembersOnline', () => {
+ it('does not count the current user already in the watcher set', () => {
+ const { channel } = makeChannel({ me: fromPartial({ id: 'me' }) });
+
+ const { result } = renderForChannel(channel);
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns true when another user is watching', () => {
+ const { channel } = makeChannel({
+ me: fromPartial({ id: 'me' }),
+ other: fromPartial({ id: 'other' }),
+ });
+
+ const { result } = renderForChannel(channel);
+
+ expect(result.current).toBe(true);
+ });
+
+ it('reactively reflects watcherStore changes', () => {
+ const { channel, watcherStore } = makeChannel();
+
+ const { result } = renderForChannel(channel);
+ expect(result.current).toBe(false);
+
+ setWatchers(watcherStore, { other: fromPartial({ id: 'other' }) });
+ expect(result.current).toBe(true);
+
+ setWatchers(watcherStore, {});
+ expect(result.current).toBe(false);
+ });
+
+ it('ignores the current user starting to watch', () => {
+ const { channel, watcherStore } = makeChannel();
+
+ const { result } = renderForChannel(channel);
+ setWatchers(watcherStore, { me: fromPartial({ id: 'me' }) });
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when disabled, even with other watchers', () => {
+ const { channel } = makeChannel({
+ other: fromPartial({ id: 'other' }),
+ });
+
+ const { result } = renderForChannel(channel, { enabled: false });
+
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts b/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts
index 237018f252..2fde2ae010 100644
--- a/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts
+++ b/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts
@@ -1,69 +1,37 @@
-import { useEffect, useState } from 'react';
-import type { Channel, ChannelState } from 'stream-chat';
+import type { Channel, WatcherState } from 'stream-chat';
-import { useChannelStateContext } from '../../../context/ChannelStateContext';
+import { useChannel } from '../../../context';
import { useChatContext } from '../../../context/ChatContext';
+import { useStateStore } from '../../../store';
export type UseChannelHasMembersOnlineParams = {
channel?: Channel;
enabled?: boolean;
};
-// Watchers include the current user, but "are other members online" must reflect
-// only other users — otherwise (e.g. in a DM) the other participant looks online
-// as soon as the local user starts watching.
-const getOtherWatchers = (
- watchers: ChannelState['watchers'] | undefined,
- ownUserId?: string,
-) => {
- const next = Object.assign({}, watchers ?? {});
- if (ownUserId) delete next[ownUserId];
- return next;
-};
-
+const watchersSelector = (nextValue: WatcherState) => ({
+ watchers: nextValue.watchers,
+});
+
+/**
+ * Reactively reports whether OTHER members are currently online (watching the channel).
+ *
+ * Subscribes to the channel's `watcherStore` instead of manually tracking
+ * `user.watching.start/stop` events. Watchers include the current user, but "are other
+ * members online" must reflect only other users — otherwise (e.g. in a DM) the other
+ * participant looks online as soon as the local user starts watching.
+ */
export const useChannelHasMembersOnline = ({
channel: channelOverride,
enabled = true,
}: UseChannelHasMembersOnlineParams = {}) => {
- const { channel: contextChannel } = useChannelStateContext();
- const { client } = useChatContext();
+ const contextChannel = useChannel();
const channel = channelOverride ?? contextChannel;
- const ownUserId = client.user?.id;
- const [watchers, setWatchers] = useState(() =>
- getOtherWatchers(channel?.state?.watchers, ownUserId),
- );
-
- useEffect(() => {
- setWatchers(getOtherWatchers(channel?.state?.watchers, ownUserId));
- }, [channel, ownUserId]);
-
- useEffect(() => {
- if (!enabled || !channel) return;
-
- const startSubscription = channel.on('user.watching.start', (event) => {
- setWatchers((prev) => {
- if (!event.user?.id || event.user.id === ownUserId) return prev;
- if (prev[event.user.id]) return prev;
- return Object.assign({ [event.user.id]: event.user }, prev);
- });
- });
- const stopSubscription = channel.on('user.watching.stop', (event) => {
- setWatchers((prev) => {
- if (!event.user?.id || !prev[event.user.id]) return prev;
-
- const next = Object.assign({}, prev);
- delete next[event.user.id];
- return next;
- });
- });
-
- return () => {
- startSubscription.unsubscribe();
- stopSubscription.unsubscribe();
- };
- }, [channel, enabled, ownUserId]);
+ const { client } = useChatContext();
+ const { watchers } = useStateStore(channel.state.watcherStore, watchersSelector);
if (!enabled) return false;
- return Object.keys(watchers).length > 0;
+ const ownUserId = client.user?.id;
+ return Object.keys(watchers).some((id) => id !== ownUserId);
};
diff --git a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts
index b6fa4fafa4..99b5873dd5 100644
--- a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts
+++ b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts
@@ -1,36 +1,36 @@
-import { useChannelStateContext } from '../../../context/ChannelStateContext';
-import { useChatContext } from '../../../context/ChatContext';
-import { useTranslationContext } from '../../../context/TranslationContext';
-import { isDmChannel } from '../../../utils';
+import type { MembersState, WatcherState } from 'stream-chat';
+
+import { useChannel, useTranslationContext } from '../../../context';
+import { useStateStore } from '../../../store';
+import { useIsDmChannel } from '../../../hooks';
import { useChannelHasMembersOnline } from './useChannelHasMembersOnline';
-import type { Channel } from 'stream-chat';
-export type UseChannelHeaderOnlineStatusParams = {
- channel?: Channel;
- watcherCount?: number;
-};
+const memberCountSelector = (nextValue: MembersState) => ({
+ memberCount: nextValue.memberCount,
+});
+
+const watcherCountSelector = (nextValue: WatcherState) => ({
+ watcherCount: nextValue.watcherCount,
+});
/**
- * Returns the channel header online status text (e.g. "Online", "Offline", or "X members, Y online").
- * Returns null when the channel has no members (nothing to show).
+ * Returns the channel header online status text (e.g. "Online", "Offline", or
+ * "X members · Y online"). Returns null when the channel has no members (nothing to show).
+ *
+ * Reactive counts come from the channel's members/watcher state stores; DM detection and
+ * "others online" are delegated to `useIsDmChannel` / `useChannelHasMembersOnline`, which
+ * subscribe to the state they each need.
*/
-export function useChannelHeaderOnlineStatus({
- channel: channelOverride,
- watcherCount: watcherCountOverride,
-}: UseChannelHeaderOnlineStatusParams = {}): string | null {
+export function useChannelHeaderOnlineStatus(): string | null {
const { t } = useTranslationContext();
- const { client } = useChatContext();
- const { channel: contextChannel, watcherCount: contextWatcherCount = 0 } =
- useChannelStateContext();
- const channel = channelOverride ?? contextChannel;
- const watcherCount = watcherCountOverride ?? contextWatcherCount;
- const { member_count: memberCount = 0 } = channel?.data || {};
- const isDirectMessagingChannel = isDmChannel({
- channel,
- ownUserId: client.user?.id,
- });
+ const channel = useChannel();
+ const { memberCount } = useStateStore(channel.state.membersStore, memberCountSelector);
+ const { watcherCount } = useStateStore(
+ channel.state.watcherStore,
+ watcherCountSelector,
+ );
+ const isDirectMessagingChannel = useIsDmChannel();
const hasMembersOnline = useChannelHasMembersOnline({
- channel,
enabled: isDirectMessagingChannel,
});
diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx
index b3e8153db7..e17b1b25a7 100644
--- a/src/components/ChannelList/ChannelList.tsx
+++ b/src/components/ChannelList/ChannelList.tsx
@@ -1,397 +1,92 @@
-import type { ReactNode } from 'react';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import clsx from 'clsx';
-import type {
- Channel,
- ChannelFilters,
- ChannelOptions,
- ChannelSort,
- Event,
- SearchControllerState,
-} from 'stream-chat';
-
-import { useConnectionRecoveredListener } from './hooks/useConnectionRecoveredListener';
-import type {
- CustomQueryChannelsFn,
- EffectiveQueryParams,
-} from './hooks/usePaginatedChannels';
-import { usePaginatedChannels } from './hooks/usePaginatedChannels';
-import {
- useChannelListShape,
- usePrepareShapeHandlers,
-} from './hooks/useChannelListShape';
+import type { ComponentType } from 'react';
+import React, { useEffect } from 'react';
+import type { Channel, ChannelPaginator, ChannelPaginatorState } from 'stream-chat';
+
+import { ChannelListItem } from '../ChannelListItem';
+import { EmptyStateIndicator } from '../EmptyStateIndicator';
+import { InfiniteScrollWithComponents } from '../InfiniteScrollPaginator/InfiniteScrollWithComponents';
+import { LoadingChannels, LoadingIndicator } from '../Loading';
+import { useChatContext } from '../../context/ChatContext';
+import { useComponentContext } from '../../context/ComponentContext';
import { useStateStore } from '../../store';
-import { ChannelListUI as DefaultChannelListUI } from './ChannelListUI';
-import { ChannelListItem } from '../ChannelListItem/ChannelListItem';
-import { Search as DefaultSearch } from '../Search';
-import type { EmptyStateIndicatorProps } from '../EmptyStateIndicator';
-import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator';
-import type { LoadMorePaginatorProps } from '../LoadMore/LoadMorePaginator';
-import { LoadMorePaginator } from '../LoadMore/LoadMorePaginator';
-import { NotificationList as DefaultNotificationList } from '../Notifications';
-import type { ChatContextValue } from '../../context';
-import {
- ChannelListContextProvider,
- DialogManagerProvider,
- useChatContext,
- useComponentContext,
-} from '../../context';
-import { moveChannelUpwards } from './utils';
-import type { TranslationContextValue } from '../../context/TranslationContext';
-import type { PaginatorProps } from '../../types/types';
-import { ChannelListHeader } from './ChannelListHeader';
-import { useStableId } from '../UtilityComponents/useStableId';
-
-const DEFAULT_FILTERS = {};
-const DEFAULT_OPTIONS = {};
-const DEFAULT_SORT = {};
-
-const searchControllerStateSelector = (nextValue: SearchControllerState) => ({
- searchIsActive: nextValue.isActive,
-});
export type ChannelListProps = {
- /**
- * When the client receives `message.new`, `notification.message_new`, and `notification.added_to_channel` events, we automatically
- * push that channel to the top of the list. If the channel doesn't currently exist in the list, we grab the channel from
- * `client.activeChannels` and push it to the top of the list. You can disable this behavior by setting this prop
- * to false, which will prevent channels not in the list from incrementing the list. The default is true.
- */
- allowNewMessagesFromUnfilteredChannels?: boolean;
- /** Optional function to filter channels prior to loading in the DOM. Do not use any complex or async logic that would delay the loading of the ChannelList. We recommend using a pure function with array methods like filter/sort/reduce. */
- channelRenderFilterFn?: (channels: Array) => Array;
- // FIXME: how is this even legal (WHY IS IT STRING?!)
- /** Set a channel (with this ID) to active and manually move it to the top of the list */
- customActiveChannel?: string;
- /** Custom function that handles the channel pagination. Has to build query filters, sort and options and query and append channels to the current channels state and update the hasNext pagination flag after each query. */
- customQueryChannels?: CustomQueryChannelsFn;
- /** Custom UI component for rendering an empty list, defaults to and accepts same props as: [EmptyStateIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx) */
- EmptyStateIndicator?: React.ComponentType;
- /** An object containing channel query filters */
- filters?: ChannelFilters;
- /** Custom function that generates the message preview in ChannelPreview component */
- getLatestMessagePreview?: (
- channel: Channel,
- t: TranslationContextValue['t'],
- userLanguage: TranslationContextValue['userLanguage'],
- isMessageAIGenerated?: ChatContextValue['isMessageAIGenerated'],
- ) => ReactNode;
- /** When true, channels won't dynamically sort by most recent message */
- lockChannelOrder?: boolean;
- /** Function to override the default behavior when a user is added to a channel, corresponds to [notification.added\_to\_channel](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) event */
- onAddedToChannel?: (
- setChannels: React.Dispatch>>,
- event: Event,
- ) => void;
- /** Function to override the default behavior when a channel is deleted, corresponds to [channel.deleted](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) event */
- onChannelDeleted?: (
- setChannels: React.Dispatch>>,
- event: Event,
- ) => void;
- /** Function to override the default behavior when a channel is hidden, corresponds to [channel.hidden](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) event */
- onChannelHidden?: (
- setChannels: React.Dispatch>>,
- event: Event,
- ) => void;
- /** Function to override the default behavior when a channel is truncated, corresponds to [channel.truncated](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) event */
- onChannelTruncated?: (
- setChannels: React.Dispatch>>,
- event: Event,
- ) => void;
- /** Function to override the default behavior when a channel is updated, corresponds to [channel.updated](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) event */
- onChannelUpdated?: (
- setChannels: React.Dispatch>>,
- event: Event,
- ) => void;
- /** Function to override the default channel visible behavior, corresponds to [channel.visible](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) event */
- onChannelVisible?: (
- setChannels: React.Dispatch>>,
- event: Event,
- ) => void;
- /** Function to override the default behavior when a message is received on a channel not being watched, corresponds to [notification.message\_new](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) event */
- onMessageNew?: (
- setChannels: React.Dispatch>>,
- event: Event,
- ) => void;
- /** Function to override the default behavior when a message is received on a channel being watched, handles [message.new](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) event */
- onMessageNewHandler?: (
- setChannels: React.Dispatch>>,
- event: Event,
- ) => void;
- /** Function to override the default behavior when a user gets removed from a channel, corresponds to [notification.removed\_from\_channel](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) event */
- onRemovedFromChannel?: (
- setChannels: React.Dispatch>>,
- event: Event,
- ) => void;
- /** An object containing channel query options */
- options?: ChannelOptions;
- /** Custom UI component to handle channel pagination logic, defaults to and accepts same props as: [LoadMorePaginator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/LoadMore/LoadMorePaginator.tsx) */
- Paginator?: React.ComponentType;
- /**
- * Custom interval during which the recovery channel list queries will be prevented.
- * This is to avoid firing unnecessary queries during internet connection fluctuation.
- * Recovery channel query is triggered upon `connection.recovered` and leads to complete channel list reload with pagination offset 0.
- * The minimum throttle interval is 2000ms. The default throttle interval is 5000ms.
- */
- recoveryThrottleIntervalMs?: number;
- /** Function to override the default behavior when rendering channels, so this function is called instead of rendering the Preview directly */
- renderChannels?: (
- channels: Channel[],
- channelPreview: (item: Channel) => React.ReactNode,
- ) => React.ReactNode;
- /** If true, sends the list's currently loaded channels to the `List` component as the `loadedChannels` prop */
- sendChannelsToList?: boolean;
- /** Last channel will be set as active channel if true, defaults to true */
- setActiveChannelOnMount?: boolean;
- /** Whether or not to load the list with a search component, defaults to false */
- showChannelSearch?: boolean;
- /** An object containing channel query sort parameters */
- sort?: ChannelSort;
- /** An object containing query parameters for fetching channel watchers */
- watchers?: { limit?: number; offset?: number };
+ /** The `ChannelPaginator` that supplies this list's channels (its data source). */
+ paginator: ChannelPaginator;
+ loadMoreDebounceMs?: number;
+ loadMoreThresholdPx?: number;
};
-const UnMemoizedChannelList = (props: ChannelListProps) => {
- const {
- allowNewMessagesFromUnfilteredChannels = true,
- channelRenderFilterFn,
- customActiveChannel,
- customQueryChannels,
- EmptyStateIndicator = DefaultEmptyStateIndicator,
- filters = {},
- getLatestMessagePreview,
- lockChannelOrder = false,
- onAddedToChannel,
- onChannelDeleted,
- onChannelHidden,
- onChannelTruncated,
- onChannelUpdated,
- onChannelVisible,
- onMessageNew,
- onMessageNewHandler,
- onRemovedFromChannel,
- options,
- Paginator = LoadMorePaginator,
- recoveryThrottleIntervalMs,
- renderChannels,
- sendChannelsToList = false,
- setActiveChannelOnMount = true,
- showChannelSearch = false,
- sort = DEFAULT_SORT,
- watchers = {},
- } = props;
-
- const stableId = useStableId();
+const channelPaginatorStateSelector = (state: ChannelPaginatorState) => ({
+ lastQueryError: state.lastQueryError,
+});
+/**
+ * Channel list driven by a single `ChannelPaginator`. The paginator is created +
+ * coordinated by the `ChannelPaginatorsOrchestrator` on `ChatContext`; this component
+ * only renders its reactive `state` and drives pagination. Selection is not this
+ * component's concern — the `ChannelListItem` default `ListItem` opens the channel via
+ * ChatView navigation.
+ */
+export const ChannelList = ({
+ loadMoreDebounceMs,
+ loadMoreThresholdPx,
+ paginator,
+}: ChannelListProps) => {
+ const { channelPaginatorsOrchestrator, client } = useChatContext('ChannelList');
+ const { lastQueryError } = useStateStore(
+ paginator.state,
+ channelPaginatorStateSelector,
+ );
const {
- channel,
- channelsQueryState,
- client,
- customClasses,
- searchController,
- setActiveChannel,
- theme,
- useImageFlagEmojisOnWindows,
- } = useChatContext('ChannelList');
- const {
- ChannelListUI = DefaultChannelListUI,
- NotificationList = DefaultNotificationList,
- Search = DefaultSearch,
+ EmptyListIndicator = DefaultEmptyChannelList,
+ EndReachedIndicator = DefaultEndReachedIndicator,
+ FirstPageLoadingIndicator = LoadingChannels,
+ ListItem = DefaultListItem,
+ LoadingNextPageIndicator = DefaultLoadingNextPageIndicator,
} = useComponentContext();
- const channelListRef = useRef(null);
- const [channelUpdateCount, setChannelUpdateCount] = useState(0);
-
- // Indicator relevant when Search component that relies on SearchController is used
- const { searchIsActive } = useStateStore(
- searchController.state,
- searchControllerStateSelector,
+ // Ref-counted: safe whether called here, from , or from .
+ useEffect(
+ () => channelPaginatorsOrchestrator.registerSubscriptions(),
+ [channelPaginatorsOrchestrator],
);
- /**
- * 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.
- */
- const activeChannelHandler = async (
- channels: Array,
- setChannels: React.Dispatch>>,
- effectiveQueryParams: EffectiveQueryParams,
- ) => {
- if (!channels.length) {
- return;
- }
-
- if (customActiveChannel) {
- // FIXME: this is wrong...
- let customActiveChannelObject = channels.find(
- (chan) => chan.id === customActiveChannel,
- );
-
- if (!customActiveChannelObject) {
- [customActiveChannelObject] = await client.queryChannels({
- id: customActiveChannel,
- });
- }
-
- if (customActiveChannelObject) {
- setActiveChannel(customActiveChannelObject, watchers);
-
- const newChannels = moveChannelUpwards({
- channels,
- channelToMove: customActiveChannelObject,
- sort: effectiveQueryParams.sort,
- });
-
- setChannels(newChannels);
- return;
- }
- }
-
- if (setActiveChannelOnMount) {
- setActiveChannel(channels[0], watchers);
- }
- };
-
- /**
- * For some events, inner properties on the channel will update but the shallow comparison will not
- * force a re-render. Incrementing this dummy variable ensures the channel previews update.
- */
- const forceUpdate = useCallback(() => setChannelUpdateCount((count) => count + 1), []);
-
- const {
- channels,
- effectiveFilters,
- effectiveSort,
- hasNextPage,
- loadNextPage,
- setChannels,
- } = usePaginatedChannels(
- client,
- filters || DEFAULT_FILTERS,
- sort || DEFAULT_SORT,
- options || DEFAULT_OPTIONS,
- activeChannelHandler,
- recoveryThrottleIntervalMs,
- customQueryChannels,
- );
-
- const loadedChannels = channelRenderFilterFn
- ? channelRenderFilterFn(channels)
- : channels;
-
- const { customHandler, defaultHandler } = usePrepareShapeHandlers({
- allowNewMessagesFromUnfilteredChannels,
- // `effectiveFilters`/`effectiveSort` reflect the backend-resolved
- // `predefined_filter` metadata when `options.predefined_filter` is in use.
- // For non-predefined queries they fall back to the caller-supplied
- // `filters`/`sort` props so behavior is unchanged.
- filters: effectiveFilters,
- lockChannelOrder,
- onAddedToChannel,
- onChannelDeleted,
- onChannelHidden,
- onChannelTruncated,
- onChannelUpdated,
- onChannelVisible,
- onMessageNew,
- onMessageNewHandler,
- onRemovedFromChannel,
- setChannels,
- sort: effectiveSort,
- // TODO: implement
- // customHandleChannelListShape
- });
-
- useChannelListShape(customHandler ?? defaultHandler);
-
- // TODO: maybe move this too
- useConnectionRecoveredListener(forceUpdate);
useEffect(() => {
- const handleEvent = (event: Event) => {
- if (event.cid === channel?.cid) {
- setActiveChannel();
- }
- };
-
- client.on('channel.deleted', handleEvent);
- client.on('channel.hidden', handleEvent);
-
- return () => {
- client.off('channel.deleted', handleEvent);
- client.off('channel.hidden', handleEvent);
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [channel?.cid]);
+ if (paginator.items) return;
+ paginator.nextDebounced();
+ }, [paginator]);
- const renderChannel = (item: Channel) => {
- const previewProps = {
- activeChannel: channel,
- channel: item,
- // forces the update of preview component on channel update
- channelUpdateCount,
- getLatestMessagePreview,
- setActiveChannel,
- watchers,
- };
-
- return ;
- };
-
- const baseClass = 'str-chat__channel-list';
- const className = clsx(
- customClasses?.chat ?? 'str-chat',
- theme,
- customClasses?.channelList ?? `${baseClass}`,
- {
- 'str-chat--windows-flags':
- useImageFlagEmojisOnWindows && navigator.userAgent.match(/Win/),
- },
- );
+ useEffect(() => {
+ if (!lastQueryError) return;
+ client.notifications.addError({
+ message: lastQueryError.message,
+ origin: { context: { reason: 'channel query error' }, emitter: 'ChannelList' },
+ });
+ }, [client, lastQueryError]);
- const showChannelList = !searchIsActive;
return (
-
-
-
-
- {showChannelSearch && }
- {showChannelList && (
-
- {!loadedChannels?.length ? (
-
- ) : (
-
- {renderChannels
- ? renderChannels(loadedChannels, renderChannel)
- : loadedChannels.map((channel) => renderChannel(channel))}
-
- )}
-
- )}
-
-
-
-
+
+ EmptyListIndicator={EmptyListIndicator}
+ EndReachedIndicator={EndReachedIndicator}
+ FirstPageLoadingIndicator={FirstPageLoadingIndicator}
+ ListItem={ListItem as ComponentType<{ item: Channel }>}
+ LoadingNextPageIndicator={LoadingNextPageIndicator}
+ loadNextDebounceMs={loadMoreDebounceMs}
+ loadNextOnScrollToBottom={paginator.next}
+ paginator={paginator}
+ threshold={loadMoreThresholdPx}
+ />
);
};
-/**
- * Renders a preview list of Channels, allowing you to select the Channel you want to open
- */
-export const ChannelList = React.memo(
- UnMemoizedChannelList,
-) as typeof UnMemoizedChannelList;
+const DefaultEmptyChannelList = () => ;
+
+const DefaultEndReachedIndicator = () => null;
+
+const DefaultListItem = ({ item }: { item: unknown }) => (
+
+);
+
+const DefaultLoadingNextPageIndicator = ({ isLoading }: { isLoading?: boolean }) =>
+ isLoading ? : null;
diff --git a/src/components/ChannelList/ChannelListHeader.tsx b/src/components/ChannelList/ChannelListHeader.tsx
index ca47e2444d..a4a255017a 100644
--- a/src/components/ChannelList/ChannelListHeader.tsx
+++ b/src/components/ChannelList/ChannelListHeader.tsx
@@ -1,18 +1,16 @@
import React from 'react';
-import {
- useChatContext,
- useComponentContext,
- useTranslationContext,
-} from '../../context';
+import { useComponentContext, useTranslationContext } from '../../context';
+import { useSlotChannels } from '../ChatView';
export const ChannelListHeader = () => {
const { t } = useTranslationContext();
- const { channel } = useChatContext();
const { HeaderEndContent } = useComponentContext();
+ // A channel is "active" when one is bound in a channel slot (mirrors ThreadListHeader).
+ const hasActiveChannel = useSlotChannels().length > 0;
return (
{t('Chats')}
- {channel && HeaderEndContent &&
}
+ {hasActiveChannel && HeaderEndContent &&
}
);
};
diff --git a/src/components/ChannelList/ChannelListUI.tsx b/src/components/ChannelList/ChannelListUI.tsx
deleted file mode 100644
index cfc630da34..0000000000
--- a/src/components/ChannelList/ChannelListUI.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react';
-import type { PropsWithChildren } from 'react';
-import type { APIErrorResponse, Channel, ErrorFromResponse } from 'stream-chat';
-
-import { LoadingChannels } from '../Loading/LoadingChannels';
-import { NullComponent } from '../UtilityComponents';
-import { useComponentContext, useTranslationContext } from '../../context';
-
-export type ChannelListUIProps = {
- /** Whether the channel query request returned an errored response */
- error: ErrorFromResponse | null;
- /** The channels currently loaded in the list, only defined if `sendChannelsToList` on `ChannelList` is true */
- loadedChannels?: Channel[];
- /** Whether the channels are currently loading */
- loading?: boolean;
- /** Local state hook that resets the currently loaded channels */
- setChannels?: React.Dispatch>;
-};
-
-/**
- * A preview list of channels, allowing you to select the channel you want to open
- */
-export const ChannelListUI = (props: PropsWithChildren) => {
- const { children, error = null, loading = false } = props;
- const { LoadingErrorIndicator = NullComponent, LoadingIndicator = LoadingChannels } =
- useComponentContext('ChannelListUI');
- const { t } = useTranslationContext('ChannelListUI');
-
- if (error) {
- return ;
- }
-
- return (
-
-
- {loading ? : children}
-
-
- );
-};
diff --git a/src/components/ChannelList/ChannelLists.tsx b/src/components/ChannelList/ChannelLists.tsx
new file mode 100644
index 0000000000..9919145bbb
--- /dev/null
+++ b/src/components/ChannelList/ChannelLists.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import type { ChannelPaginatorsOrchestratorState } from 'stream-chat';
+
+import { ChannelListContextProvider } from '../../context/ChannelListContext';
+import { useChatContext } from '../../context/ChatContext';
+import { useStateStore } from '../../store';
+import { ChannelList } from './ChannelList';
+
+const paginatorsSelector = (state: ChannelPaginatorsOrchestratorState) => ({
+ paginators: state.paginators,
+});
+
+/**
+ * Renders one `` per paginator held by the `ChannelPaginatorsOrchestrator` on
+ * `ChatContext` — i.e. its data source is the orchestrator (the whole set of lists). Each child
+ * `ChannelList` registers the (ref-counted) WS subscriptions. The primary (`paginators[0]`)
+ * paginator is exposed through `ChannelListContext` so descendants (search results, member
+ * actions, notification targeting) can read/mutate the loaded list without knowing about the
+ * orchestrator.
+ */
+export const ChannelLists = () => {
+ const { channelPaginatorsOrchestrator } = useChatContext('ChannelLists');
+ const { paginators } = useStateStore(
+ channelPaginatorsOrchestrator.state,
+ paginatorsSelector,
+ );
+
+ const lists = (
+ <>
+ {paginators.map((paginator) => (
+
+ ))}
+ >
+ );
+
+ const primaryPaginator = paginators[0];
+ if (!primaryPaginator) return lists;
+
+ return (
+
+ {lists}
+
+ );
+};
diff --git a/src/components/ChannelList/ChannelNavigation.tsx b/src/components/ChannelList/ChannelNavigation.tsx
new file mode 100644
index 0000000000..497b684c28
--- /dev/null
+++ b/src/components/ChannelList/ChannelNavigation.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import type { SearchControllerState } from 'stream-chat';
+
+import { ChannelListHeader } from './ChannelListHeader';
+import { ChannelLists } from './ChannelLists';
+import { NotificationList as DefaultNotificationList } from '../Notifications';
+import { Search as DefaultSearch } from '../Search';
+import { useChatContext, useComponentContext } from '../../context';
+import { useStateStore } from '../../store';
+
+const searchControllerStateSelector = (state: SearchControllerState) => ({
+ isActive: state.isActive,
+});
+
+/**
+ * Channel navigation region: owns the search-results ↔ channel-list swap so the
+ * list components stay pure. Renders the `ChannelListHeader`, then `Search` (bar
+ * always; results while active), then the paginator-driven `ChannelLists` only while
+ * search is inactive. Mapped to the channel-navigation slot; `Search` is customizable
+ * via the `ComponentContext` `Search` slot.
+ */
+export const ChannelNavigation = () => {
+ const { NotificationList = DefaultNotificationList, Search = DefaultSearch } =
+ useComponentContext();
+ const { searchController } = useChatContext();
+ const { isActive } = useStateStore(
+ searchController.state,
+ searchControllerStateSelector,
+ );
+
+ return (
+
+
+
+ {!isActive && }
+
+
+ );
+};
diff --git a/src/components/ChannelList/__tests__/ChannelList.test.tsx b/src/components/ChannelList/__tests__/ChannelList.test.tsx
deleted file mode 100644
index d1e5ce12cc..0000000000
--- a/src/components/ChannelList/__tests__/ChannelList.test.tsx
+++ /dev/null
@@ -1,2345 +0,0 @@
-import React, { useEffect } from 'react';
-import { nanoid } from 'nanoid';
-import { SearchController } from 'stream-chat';
-import type {
- Channel,
- ChannelAPIResponse,
- ChannelFilters,
- LocalMessage,
- StreamChat,
- UserResponse,
-} from 'stream-chat';
-import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { fromPartial } from '@total-typescript/shoehorn';
-import type { Mock } from 'vitest';
-import { axe } from '../../../../axe-helper';
-
-import {
- dispatchChannelDeletedEvent,
- dispatchChannelHiddenEvent,
- dispatchChannelTruncatedEvent,
- dispatchChannelUpdatedEvent,
- dispatchChannelVisibleEvent,
- dispatchConnectionRecoveredEvent,
- dispatchMessageNewEvent,
- dispatchNotificationAddedToChannelEvent,
- dispatchNotificationMessageNewEvent,
- dispatchNotificationRemovedFromChannel,
- erroredPostApi,
- generateChannel,
- generateMember,
- generateMessage,
- generateUser,
- getOrCreateChannelApi,
- getTestClientWithUser,
- initClientWithChannels,
- mockTranslationContextValue,
- queryChannelsApi,
- queryUsersApi,
- useMockedApis,
-} from '../../../mock-builders';
-
-import { Chat } from '../../Chat';
-import { ChannelList } from '../ChannelList';
-import { ChannelListItemUI } from '../../ChannelListItem';
-
-import {
- type ChannelListContextValue,
- ChatContext,
- type ChatContextValue,
- TranslationProvider,
- useChannelListContext,
- useChatContext,
- WithComponents,
-} from '../../../context';
-import { ChannelListUI } from '../ChannelListUI';
-import type { ChannelListProps } from '../ChannelList';
-
-const channelsQueryStateMock = {
- error: null,
- queryInProgress: null,
- setError: vi.fn(),
- setQueryInProgress: vi.fn(),
-};
-
-/**
- * We use the following custom UI components for preview and list.
- * If we use ChannelPreviewMessenger or ChannelPreviewLastMessage here, then changes
- * to those components might end up breaking tests for ChannelList, which will be quite painful
- * to debug then.
- */
-const ChannelPreviewComponent = ({
- channel,
- channelUpdateCount,
- latestMessagePreview,
-}) => (
-
-
{channelUpdateCount}
-
{channel.data.name}
-
{latestMessagePreview}
-
-);
-
-const ChannelListComponent = (props) => {
- const { error, loading } = props;
- if (error) {
- return ;
- }
-
- if (loading) {
- return ;
- }
-
- return {props.children}
;
-};
-const ROLE_LIST_ITEM_SELECTOR = '[role="listitem"]';
-const SEARCH_RESULT_LIST_SELECTOR = '.str-chat__search-results';
-describe('ChannelList', () => {
- let chatClient: StreamChat;
- let testChannel1: ChannelAPIResponse;
- let testChannel2: ChannelAPIResponse;
- let testChannel3: ChannelAPIResponse;
-
- beforeEach(async () => {
- chatClient = await getTestClientWithUser({ id: 'uthred' });
- testChannel1 = generateChannel();
- testChannel2 = generateChannel();
- testChannel3 = generateChannel();
- });
-
- afterEach(() => {
- cleanup();
- vi.useRealTimers();
- vi.restoreAllMocks();
- });
-
- it('should re-query channels when filters change', async () => {
- const props = {
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- };
-
- useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
-
- const { container, getByRole, getByTestId, rerender } = render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- useMockedApis(chatClient, [queryChannelsApi([testChannel2])]);
- rerender(
-
-
- ({ dummyFilter: true })}
- />
-
- ,
- );
- await waitFor(() => {
- expect(getByTestId(testChannel2.channel.id)).toBeInTheDocument();
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should use custom query channels function instead of default channels query', async () => {
- const { channels, client } = await initClientWithChannels({
- channelsData: [testChannel1],
- });
- const props = {
- customQueryChannels: vi
- .fn()
- .mockImplementationOnce(({ currentChannels, setChannels, setHasNextPage }) => {
- if (!currentChannels.length) setChannels([channels[0]]);
- setHasNextPage(true);
- }),
- filters: {},
- };
- const queryChannelsMock = vi
- .spyOn(client, 'queryChannels')
- .mockImplementationOnce(() => Promise.resolve([]));
-
- const { rerender } = render(
-
-
- ,
- );
-
- await waitFor(() => {
- expect(queryChannelsMock).toHaveBeenCalledTimes(0);
- expect(props.customQueryChannels).toHaveBeenCalledTimes(1);
- });
- await waitFor(() => {
- expect(props.customQueryChannels).toHaveBeenCalledWith(
- expect.objectContaining({
- currentChannels: [],
- queryType: 'reload',
- setChannels: expect.any(Function),
- setHasNextPage: expect.any(Function),
- }),
- );
- });
-
- rerender(
-
-
- ,
- );
-
- act(() => {
- fireEvent.click(screen.getByTestId('load-more-button'));
- });
-
- await waitFor(() => {
- expect(queryChannelsMock).toHaveBeenCalledTimes(0);
- expect(props.customQueryChannels).toHaveBeenCalledTimes(2);
- });
- await waitFor(() => {
- expect(props.customQueryChannels).toHaveBeenCalledWith(
- expect.objectContaining({
- currentChannels: [channels[0]],
- queryType: 'load-more',
- setChannels: expect.any(Function),
- setHasNextPage: expect.any(Function),
- }),
- );
- });
-
- queryChannelsMock.mockRestore();
- });
-
- it('should only show filtered channels when a filter function prop is provided', async () => {
- const filteredChannel = generateChannel({ channel: { type: 'filtered' } });
-
- const customFilterFunction = (channels) =>
- channels.filter((channel) => channel.type === 'filtered');
-
- const props = {
- channelRenderFilterFn: customFilterFunction,
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- };
-
- useMockedApis(chatClient, [queryChannelsApi([filteredChannel, testChannel1])]);
-
- const { container, getByRole, queryAllByRole } = render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
-
- expect(queryAllByRole('listitem')).toHaveLength(1);
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('renders Search component if searchController state indicates active search', async () => {
- const searchController = new SearchController();
- const searchTestId = 'search-test-id';
- const Search = () => ;
- const client = await getTestClientWithUser({ id: 'userId' });
-
- render(
-
-
-
-
- ,
- );
- await act(() => {
- searchController.activate();
- });
- expect(screen.getByTestId(searchTestId)).toBeInTheDocument();
- });
-
- it('should render `LoadingErrorIndicator` when queryChannels api throws error', async () => {
- useMockedApis(chatClient, [erroredPostApi()]);
- vi.spyOn(console, 'warn').mockImplementationOnce(() => null);
-
- const { container, getByTestId } = render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByTestId('error-indicator')).toBeInTheDocument();
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should add a notification when the first page of channels fails to load', async () => {
- useMockedApis(chatClient, [erroredPostApi()]);
- const addNotificationSpy = vi.spyOn(chatClient.notifications, 'add');
- vi.spyOn(console, 'warn').mockImplementation(() => null);
-
- render(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(addNotificationSpy).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Failed to load channels',
- options: expect.objectContaining({
- severity: 'error',
- tags: expect.arrayContaining(['target:channel-list']),
- type: 'api:channel-list:load:failed',
- }),
- origin: { emitter: 'ChannelList' },
- }),
- );
- });
- });
-
- it('provides the error object to LoadingErrorIndicator', async () => {
- useMockedApis(chatClient, [erroredPostApi()]);
- vi.spyOn(console, 'warn').mockImplementationOnce(() => null);
-
- const LoadingErrorIndicator = (props) => {props.error.message}
;
-
- await act(async () => {
- await render(
-
-
-
-
- ,
- );
- });
-
- expect(screen.getByText('StreamChat error HTTP code: 500')).toBeInTheDocument();
- });
-
- it('should keep loaded channels visible and add a notification when loading more fails', async () => {
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
- const addNotificationSpy = vi.spyOn(chatClient.notifications, 'add');
- vi.spyOn(console, 'warn').mockImplementation(() => null);
-
- render(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(screen.getByTestId(testChannel1.channel.id)).toBeInTheDocument();
- expect(screen.getByTestId(testChannel2.channel.id)).toBeInTheDocument();
- });
-
- useMockedApis(chatClient, [erroredPostApi()]);
-
- await act(() => {
- fireEvent.click(screen.getByTestId('load-more-button'));
- });
-
- await waitFor(() => {
- expect(addNotificationSpy).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Failed to load more channels',
- options: expect.objectContaining({
- severity: 'error',
- tags: expect.arrayContaining(['target:channel-list']),
- type: 'api:channel-list:load-more:failed',
- }),
- origin: { emitter: 'ChannelList' },
- }),
- );
- });
-
- expect(screen.getByTestId(testChannel1.channel.id)).toBeInTheDocument();
- expect(screen.getByTestId(testChannel2.channel.id)).toBeInTheDocument();
- expect(screen.queryByTestId('error-indicator')).not.toBeInTheDocument();
- });
-
- it('should render loading indicator before the first channel list load and on reload', async () => {
- const channelsQueryStatesHistory = [];
- const channelListMessengerLoadingHistory = [];
- useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
-
- const QueryStateInterceptor = ({ children }) => {
- const { channelsQueryState } = useChatContext();
- channelsQueryStatesHistory.push(channelsQueryState.queryInProgress);
- return children;
- };
-
- const ChannelListMessengerPropsInterceptor = (props) => {
- channelListMessengerLoadingHistory.push(props.loading);
- return ;
- };
-
- await render(
-
-
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(channelListMessengerLoadingHistory.length).toBeGreaterThanOrEqual(2);
- // The first render should show loading=true (covering both 'uninitialized' and 'reload' states)
- expect(channelListMessengerLoadingHistory[0]).toBe(true);
- // The last render should show loading=false
- expect(
- channelListMessengerLoadingHistory[channelListMessengerLoadingHistory.length - 1],
- ).toBe(false);
- // Query state transitions: may batch 'uninitialized' -> 'reload', then -> null
- expect(channelsQueryStatesHistory.length).toBeGreaterThanOrEqual(2);
- expect(channelsQueryStatesHistory).toContain('reload');
- expect(
- channelsQueryStatesHistory[channelsQueryStatesHistory.length - 1],
- ).toBeNull();
- });
- });
-
- it('ChannelPreview UI components should render `Avatar` when the custom prop is provided', async () => {
- useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
-
- const { getByTestId } = render(
-
- Avatar
,
- ChannelListItemUI,
- }}
- >
-
-
- ,
- );
-
- await waitFor(() => {
- expect(getByTestId('custom-avatar-messenger')).toBeInTheDocument();
- });
- });
-
- it('when queryChannels api returns no channels, `EmptyStateIndicator` should be rendered', async () => {
- useMockedApis(chatClient, [queryChannelsApi([])]);
-
- const EmptyStateIndicator = () => (
-
- );
-
- const { container, getByTestId } = render(
-
-
-
-
- ,
- );
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByTestId('empty-state-indicator')).toBeInTheDocument();
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should show unique channels', async () => {
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
- const ChannelPreview = (props) => (
-
- );
- render(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(screen.getByTestId(testChannel1.channel.id)).toBeInTheDocument();
- expect(screen.getByTestId(testChannel2.channel.id)).toBeInTheDocument();
- expect(screen.getAllByRole('listitem')).toHaveLength(2);
- });
-
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel3])]);
-
- await act(() => {
- fireEvent.click(screen.getByTestId('load-more-button'));
- });
-
- await waitFor(() => {
- expect(screen.getByTestId(testChannel1.channel.id)).toBeInTheDocument();
- expect(screen.getByTestId(testChannel2.channel.id)).toBeInTheDocument();
- expect(screen.getByTestId(testChannel3.channel.id)).toBeInTheDocument();
- expect(screen.getAllByRole('listitem')).toHaveLength(3);
- });
- });
-
- it('allows to customize latest message preview generation', async () => {
- const previewText = 'custom preview text';
- const getLatestMessagePreview = () => previewText;
-
- const PreviewWithLatestMessage = ({ channel, latestMessagePreview }) => (
-
-
{latestMessagePreview}
-
- );
-
- useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
- const { rerender } = render(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(screen.getByText('Nothing yet...')).toBeInTheDocument();
- });
-
- rerender(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(screen.getByText(previewText)).toBeInTheDocument();
- });
- });
-
- describe('Default and custom active channel', () => {
- let setActiveChannel: Mock;
- const watchersConfig = { limit: 20, offset: 0 };
- const testSetActiveChannelCall = (channelInstance) =>
- waitFor(() => {
- expect(setActiveChannel).toHaveBeenCalledTimes(1);
- expect(setActiveChannel).toHaveBeenCalledWith(channelInstance, watchersConfig);
- return true;
- });
-
- beforeEach(() => {
- setActiveChannel = vi.fn();
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
- });
-
- it('should call `setActiveChannel` prop function with first channel as param', async () => {
- render(
- ({
- channelsQueryState: channelsQueryStateMock,
- client: chatClient,
- searchController: new SearchController(),
- setActiveChannel,
- })}
- >
-
- ,
- );
-
- const channelInstance = chatClient.channel(
- testChannel1.channel.type,
- testChannel1.channel.id,
- );
-
- expect(await testSetActiveChannelCall(channelInstance)).toBe(true);
- });
-
- it('should call `setActiveChannel` prop function with channel (which has `customActiveChannel` id) as param', async () => {
- render(
- ({
- channelsQueryState: channelsQueryStateMock,
- client: chatClient,
- searchController: new SearchController(),
- setActiveChannel,
- })}
- >
-
- ,
- );
-
- const channelInstance = chatClient.channel(
- testChannel2.channel.type,
- testChannel2.channel.id,
- );
-
- expect(await testSetActiveChannelCall(channelInstance)).toBe(true);
- });
-
- it('should fall back to the first channel when `customActiveChannel` is not found', async () => {
- vi.mocked(chatClient.axiosInstance.post).mockReset();
- vi.mocked(chatClient.axiosInstance.post)
- .mockResolvedValueOnce(queryChannelsApi([testChannel1, testChannel2]).response)
- .mockResolvedValueOnce(queryChannelsApi([]).response);
-
- render(
- ({
- channelsQueryState: channelsQueryStateMock,
- client: chatClient,
- searchController: new SearchController(),
- setActiveChannel,
- })}
- >
-
- ,
- );
-
- const channelInstance = chatClient.channel(
- testChannel1.channel.type,
- testChannel1.channel.id,
- );
-
- expect(await testSetActiveChannelCall(channelInstance)).toBe(true);
- });
-
- it('should render channel with id `customActiveChannel` at top of the list', async () => {
- const { container, getAllByRole, getByRole, getByTestId } = render(
- ({
- channelsQueryState: channelsQueryStateMock,
- client: chatClient,
- searchController: new SearchController(),
- setActiveChannel,
- })}
- >
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(async () => {
- expect(getByRole('list')).toBeInTheDocument();
- const items = getAllByRole('listitem');
-
- // Get the closest listitem to the channel that received new message.
- const channelPreview = getByTestId(testChannel2.channel.id).closest(
- ROLE_LIST_ITEM_SELECTOR,
- );
-
- expect(channelPreview.isEqualNode(items[0])).toBe(true);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
- });
-
- describe('channel search', () => {
- const defaultSearchDebounceInterval = 300;
- const inputText = 'xxxxxxxxxx';
- const user1 = generateUser();
- const user2 = generateUser();
- const mockedChannels = Array.from({ length: 3 }, (_, i) =>
- generateChannel({
- channel: { image: `image-xxx-${i}`, name: `channel-xxx-${i}` },
- members: [generateMember({ user: user1 }), generateMember({ user: user2 })],
- messages: ' '
- .repeat(20)
- .split(' ')
- .map((v, i) => generateMessage({ user: i % 3 ? user1 : user2 })),
- }),
- );
-
- let client: StreamChat;
- let channel: Channel;
- beforeEach(async () => {
- client = await getTestClientWithUser({ id: user1.id });
-
- useMockedApis(client, [getOrCreateChannelApi(mockedChannels[0])]);
- channel = client.channel('messaging', mockedChannels[0]['id']);
- await channel.watch();
- useMockedApis(client, [
- queryChannelsApi(mockedChannels), // first API call goes to /channels endpoint
- queryUsersApi([user1, user2]), // onSearch starts searching users first
- ]);
- });
-
- const renderComponents = async (
- chatContext = {},
- channeListProps?: Partial,
- ) =>
- await act(
- async () =>
- await render(
- ({
- channelsQueryState: channelsQueryStateMock,
- searchController: new SearchController(),
- setActiveChannel,
- ...chatContext,
- })}
- >
-
-
-
- ,
- ),
- );
-
- it("should not render search results on input focus if user haven't started to type", async () => {
- const { container } = await renderComponents({ channel, client });
- const input = screen.queryByTestId('search-input');
- await act(async () => {
- await fireEvent.focus(input);
- });
-
- await waitFor(() => {
- // Search results container should be visible (with presearch) but no query results
- expect(
- container.querySelector(SEARCH_RESULT_LIST_SELECTOR),
- ).toBeInTheDocument();
- // Channel list is hidden when search is active
- expect(screen.queryByLabelText('Channel list')).not.toBeInTheDocument();
- });
- });
- it('should hide channel list and show search results when user types', async () => {
- const { container } = await renderComponents({ channel, client });
- const input = screen.queryByTestId('search-input');
- await act(async () => {
- input.focus();
- await fireEvent.change(input, {
- target: {
- value: inputText,
- },
- });
- });
- await waitFor(() => {
- expect(
- container.querySelector(SEARCH_RESULT_LIST_SELECTOR),
- ).toBeInTheDocument();
- // Channel list is hidden when search is active
- expect(screen.queryByLabelText('Channel list')).not.toBeInTheDocument();
- });
- });
- it('should show search results when user types in search input', async () => {
- const { container } = await renderComponents({ channel, client });
- const input = screen.queryByTestId('search-input');
- await act(async () => {
- input.focus();
- await fireEvent.change(input, {
- target: {
- value: inputText,
- },
- });
- });
- await waitFor(() => {
- expect(
- container.querySelector(SEARCH_RESULT_LIST_SELECTOR),
- ).toBeInTheDocument();
- expect(screen.queryByLabelText('Channel list')).not.toBeInTheDocument();
- });
- });
-
- it('should exit search and show channel list when cancel button is clicked', async () => {
- const { container } = await renderComponents({ channel, client });
- const input = screen.queryByTestId('search-input');
- await act(async () => {
- input.focus();
- await fireEvent.change(input, {
- target: {
- value: inputText,
- },
- });
- });
-
- await waitFor(() => {
- expect(screen.queryByTestId('search-bar-button')).toBeInTheDocument();
- });
-
- await act(async () => {
- const cancelButton = screen.queryByTestId('search-bar-button');
- await fireEvent.click(cancelButton);
- });
- await waitFor(() => {
- expect(
- container.querySelector(SEARCH_RESULT_LIST_SELECTOR),
- ).not.toBeInTheDocument();
- expect(input).toHaveValue('');
- expect(screen.queryByTestId('search-bar-button')).not.toBeInTheDocument();
- });
- });
-
- it('should clear search query when clear button is clicked but keep search active', async () => {
- await renderComponents({ channel, client });
- const input = screen.queryByTestId('search-input');
- await act(async () => {
- input.focus();
- await fireEvent.change(input, {
- target: {
- value: inputText,
- },
- });
- });
-
- await waitFor(() => {
- expect(screen.queryByTestId('clear-input-button')).toBeInTheDocument();
- });
-
- await act(async () => {
- const clearButton = screen.queryByTestId('clear-input-button');
- await fireEvent.click(clearButton);
- });
- await waitFor(() => {
- expect(input).toHaveValue('');
- expect(input).toHaveFocus();
- // Search remains active after clearing, cancel button should still be visible
- expect(screen.queryByTestId('search-bar-button')).toBeInTheDocument();
- });
- });
-
- it('should exit search when cancel button is clicked after typing', async () => {
- const { container } = await renderComponents({ channel, client });
- const input = screen.queryByTestId('search-input');
-
- await act(async () => {
- input.focus();
- await fireEvent.change(input, {
- target: {
- value: inputText,
- },
- });
- });
-
- const cancelButton = screen.queryByTestId('search-bar-button');
- await act(async () => {
- await fireEvent.click(cancelButton);
- });
- await waitFor(() => {
- expect(
- container.querySelector(SEARCH_RESULT_LIST_SELECTOR),
- ).not.toBeInTheDocument();
- expect(input).toHaveValue('');
- expect(cancelButton).not.toBeInTheDocument();
- });
- });
- it('should add the selected result to the top of the channel list', async () => {
- vi.useFakeTimers({ shouldAdvanceTime: true });
- vi.spyOn(client, 'queryUsers').mockResolvedValue(
- fromPartial({ users: [generateUser()] }),
- );
- await act(async () => {
- await render(
-
-
- ,
- );
- });
-
- const channelNotInTheList = generateChannel({
- channel: { name: 'channel-not-loaded-yet' },
- });
-
- await waitFor(() => {
- expect(screen.queryAllByRole('option')).toHaveLength(3);
- expect(
- screen.queryByText(channelNotInTheList.channel.name),
- ).not.toBeInTheDocument();
- });
-
- useMockedApis(client, [
- queryChannelsApi([channelNotInTheList, ...mockedChannels]),
- ]);
- const input = screen.queryByTestId('search-input');
- await act(() => {
- input.focus();
- fireEvent.change(input, {
- target: {
- value: inputText,
- },
- });
- });
- await act(() => {
- vi.advanceTimersByTime(defaultSearchDebounceInterval + 1);
- });
-
- const targetChannelPreview = screen.getByText(channelNotInTheList.channel.name);
- expect(targetChannelPreview).toBeInTheDocument();
- await act(async () => {
- await fireEvent.click(targetChannelPreview);
- });
-
- await waitFor(() => {
- expect(
- screen.queryByText(channelNotInTheList.channel.name),
- ).toBeInTheDocument();
- expect(screen.queryByTestId('return-icon')).not.toBeInTheDocument();
- });
- vi.useRealTimers();
- });
- });
- });
-
- it('should call `renderChannels` function prop, if provided', async () => {
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
- const renderChannels = vi.fn();
- const channelListProps = {
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- renderChannels,
- };
- const { container, getByRole } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- await waitFor(() => {
- expect(renderChannels).toHaveBeenCalledTimes(1);
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- describe('Event handling', () => {
- describe('message.new', () => {
- const props = {
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- };
- const sendNewMessageOnChannel3 = async () => {
- const newMessage = generateMessage({
- user: generateUser(),
- });
-
- await act(() => {
- dispatchMessageNewEvent(chatClient, newMessage, testChannel3.channel);
- });
- return newMessage;
- };
-
- beforeEach(() => {
- useMockedApis(chatClient, [
- queryChannelsApi([testChannel1, testChannel2, testChannel3]),
- ]);
- });
-
- it('should move channel to top of the list', async () => {
- const { container, getAllByRole, getByRole, getByText } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- const newMessage = await sendNewMessageOnChannel3();
- await waitFor(() => {
- expect(getByText(newMessage.text)).toBeInTheDocument();
- });
-
- const items = getAllByRole('listitem');
-
- // Get the closes listitem to the channel that received new message.
- const channelPreview = getByText(newMessage.text).closest(
- ROLE_LIST_ITEM_SELECTOR,
- );
- expect(channelPreview.isEqualNode(items[0])).toBe(true);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should not alter order if `lockChannelOrder` prop is true', async () => {
- const { container, getAllByRole, getByRole, getByText } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- const newMessage = await sendNewMessageOnChannel3();
-
- await waitFor(() => {
- expect(getByText(newMessage.text)).toBeInTheDocument();
- });
-
- const items = getAllByRole('listitem');
-
- // Get the closes listitem to the channel that received new message.
- const channelPreview = getByText(newMessage.text).closest(
- ROLE_LIST_ITEM_SELECTOR,
- );
- expect(channelPreview.isEqualNode(items[2])).toBe(true);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should execute custom event handler', async () => {
- const onMessageNewEvent = vi.fn();
- render(
-
-
-
-
- ,
- );
- const message = await sendNewMessageOnChannel3();
- await waitFor(() => {
- expect(onMessageNewEvent.mock.calls[0][0]).toStrictEqual(expect.any(Function));
- expect(onMessageNewEvent.mock.calls[0][1]).toStrictEqual(
- expect.objectContaining({
- channel: testChannel3.channel,
- cid: testChannel3.channel.cid,
- message,
- type: 'message.new',
- user: message.user,
- }),
- );
- });
- });
- });
-
- describe('notification.message_new', () => {
- it('should move channel to top of the list by default', async () => {
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
-
- const { container, getAllByRole, getByRole, getByTestId } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- useMockedApis(chatClient, [getOrCreateChannelApi(testChannel3)]);
-
- act(() => dispatchNotificationMessageNewEvent(chatClient, testChannel3.channel));
-
- await waitFor(() => {
- expect(getByTestId(testChannel3.channel.id)).toBeInTheDocument();
- });
-
- const items = getAllByRole('listitem');
-
- // Get the closes listitem to the channel that received new message.
- const channelPreview = getByTestId(testChannel3.channel.id);
- expect(channelPreview.isEqualNode(items[0])).toBe(true);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call `onMessageNew` function prop, if provided', async () => {
- const onMessageNew = vi.fn();
-
- useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
-
- const { container, getByRole } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- useMockedApis(chatClient, [getOrCreateChannelApi(testChannel2)]);
-
- act(() => dispatchNotificationMessageNewEvent(chatClient, testChannel2.channel));
-
- await waitFor(() => {
- expect(onMessageNew).toHaveBeenCalledTimes(1);
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('does not query (watch) the channel when allowNewMessagesFromUnfilteredChannels is false (#2441)', async () => {
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
-
- const { getByRole, queryByTestId } = await render(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- // getChannel() resolves `client.channel(type, id)` to this cached
- // instance, so spying on its watch() reveals whether a queryChannel was
- // issued for an unfiltered channel.
- const unlistedChannel = chatClient.channel(
- testChannel3.channel.type,
- testChannel3.channel.id,
- );
- const watchSpy = vi
- .spyOn(unlistedChannel, 'watch')
- .mockResolvedValue(fromPartial({}));
-
- await act(async () => {
- dispatchNotificationMessageNewEvent(chatClient, testChannel3.channel);
- await Promise.resolve();
- });
-
- expect(watchSpy).not.toHaveBeenCalled();
- expect(queryByTestId(testChannel3.channel.id)).not.toBeInTheDocument();
- });
- });
-
- describe('notification.added_to_channel', () => {
- const channelListProps = {
- filters: {},
-
- options: {
- limit: 25,
- message_limit: 25,
- presence: true,
- state: true,
- watch: true,
- },
- };
-
- beforeEach(async () => {
- chatClient = await getTestClientWithUser({ id: 'vishal' });
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
- });
-
- it('should move channel to top of the list by default', async () => {
- const { container, getAllByRole, getByRole, getByTestId } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- useMockedApis(chatClient, [getOrCreateChannelApi(testChannel3)]);
-
- act(() =>
- dispatchNotificationAddedToChannelEvent(chatClient, testChannel3.channel),
- );
-
- await waitFor(() => {
- expect(getByTestId(testChannel3.channel.id)).toBeInTheDocument();
- });
-
- const items = getAllByRole('listitem');
-
- // Get the closes listitem to the channel that received new message.
- const channelPreview = getByTestId(testChannel3.channel.id);
- expect(channelPreview.isEqualNode(items[0])).toBe(true);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call `onAddedToChannel` function prop, if provided', async () => {
- const onAddedToChannel = vi.fn();
- const { container, getByRole } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- act(() =>
- dispatchNotificationAddedToChannelEvent(chatClient, testChannel3.channel),
- );
-
- await waitFor(() => {
- expect(onAddedToChannel).toHaveBeenCalledTimes(1);
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
- });
-
- describe('notification.removed_from_channel', () => {
- const channelListProps = {
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- };
-
- beforeEach(() => {
- useMockedApis(chatClient, [
- queryChannelsApi([testChannel1, testChannel2, testChannel3]),
- ]);
- });
-
- it('should remove the channel from list by default', async () => {
- const { container, getByRole, getByTestId } = await render(
-
-
-
-
- ,
- );
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
- const nodeToBeRemoved = getByTestId(testChannel3.channel.id);
-
- act(() =>
- dispatchNotificationRemovedFromChannel(chatClient, testChannel3.channel),
- );
-
- await waitFor(() => {
- expect(nodeToBeRemoved).not.toBeInTheDocument();
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call `onRemovedFromChannel` function prop, if provided', async () => {
- const onRemovedFromChannel = vi.fn();
- const { container, getByRole } = await render(
-
-
-
-
- ,
- );
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- act(() =>
- dispatchNotificationRemovedFromChannel(chatClient, testChannel3.channel),
- );
-
- await waitFor(() => {
- expect(onRemovedFromChannel).toHaveBeenCalledTimes(1);
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- // Regression: #2599. Removing the current user from a channel must evict it
- // from `client.activeChannels`. Otherwise the channel lingers there and, on
- // reconnect, `recoverState()` re-watches it (`recoverStateOnReconnect` defaults
- // to `true`, so `usePaginatedChannels` does not re-query and relies on core
- // recovery) - channel events then resume and the list resurrects it. The
- // eviction itself lives in stream-chat core (`StreamChat._handleClientEvent`);
- // this test guards that the behaviour ChannelList depends on stays in place.
- it('evicts the removed channel from client.activeChannels so reconnect cannot re-watch it (#2599)', async () => {
- const { getByRole, getByTestId } = await render(
-
-
-
-
- ,
- );
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- const { cid } = testChannel3.channel;
- const removedNode = getByTestId(testChannel3.channel.id);
- // precondition: the loaded channel is tracked by the client
- expect(chatClient.activeChannels[cid]).toBeDefined();
-
- act(() =>
- dispatchNotificationRemovedFromChannel(chatClient, testChannel3.channel),
- );
-
- await waitFor(() => {
- expect(removedNode).not.toBeInTheDocument();
- });
- // the channel must no longer be tracked, otherwise `recoverState()` would
- // re-watch it on the next reconnect and bring it back into the list
- expect(chatClient.activeChannels[cid]).toBeUndefined();
- });
- });
-
- describe('channel.updated', () => {
- const channelListProps = {
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- };
-
- beforeEach(() => {
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
- });
-
- it('should update the channel in list, by default', async () => {
- const { container, getByRole, getByText } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- const newChannelName = nanoid();
- act(() =>
- dispatchChannelUpdatedEvent(chatClient, {
- ...testChannel2.channel,
- name: newChannelName,
- }),
- );
-
- await waitFor(() => {
- expect(getByText(newChannelName)).toBeInTheDocument();
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call `onChannelUpdated` function prop, if provided', async () => {
- const onChannelUpdated = vi.fn();
- const { container, getByRole } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- const newChannelName = nanoid();
-
- act(() =>
- dispatchChannelUpdatedEvent(chatClient, {
- ...testChannel2.channel,
- name: newChannelName,
- }),
- );
-
- await waitFor(() => {
- expect(onChannelUpdated).toHaveBeenCalledTimes(1);
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
- });
-
- describe('channel.deleted', () => {
- const channelListProps = {
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- };
-
- beforeEach(() => {
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
- });
-
- it('should remove channel from list, by default', async () => {
- const { container, getByRole, getByTestId } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- const nodeToBeRemoved = getByTestId(testChannel2.channel.id);
- act(() => dispatchChannelDeletedEvent(chatClient, testChannel2.channel));
-
- await waitFor(() => {
- expect(nodeToBeRemoved).not.toBeInTheDocument();
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call `onChannelDeleted` function prop, if provided', async () => {
- const onChannelDeleted = vi.fn();
- const { container, getByRole } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- act(() => dispatchChannelDeletedEvent(chatClient, testChannel2.channel));
-
- await waitFor(() => {
- expect(onChannelDeleted).toHaveBeenCalledTimes(1);
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should unset activeChannel if it was deleted', async () => {
- const setActiveChannel = vi.fn();
- const { container, getByRole } = await render(
- ({
- channel: fromPartial({ cid: testChannel1.channel.cid }),
- channelsQueryState: channelsQueryStateMock,
- client: chatClient,
- searchController: new SearchController(),
- setActiveChannel,
- })}
- >
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- setActiveChannel.mockClear();
-
- act(() => dispatchChannelDeletedEvent(chatClient, testChannel1.channel));
-
- await waitFor(() => {
- expect(setActiveChannel).toHaveBeenCalledTimes(1);
- expect(setActiveChannel).toHaveBeenCalledWith();
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
- });
-
- describe('channel.hidden', () => {
- const channelListProps = {
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- };
-
- beforeEach(() => {
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
- });
-
- it('should remove channel from list, by default', async () => {
- const { container, getByRole, getByTestId } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- const nodeToBeRemoved = getByTestId(testChannel2.channel.id);
- act(() => dispatchChannelHiddenEvent(chatClient, testChannel2.channel));
-
- await waitFor(() => {
- expect(nodeToBeRemoved).not.toBeInTheDocument();
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should unset activeChannel if it was hidden', async () => {
- const setActiveChannel = vi.fn();
- const { container, getByRole } = await render(
- ({
- channel: fromPartial({ cid: testChannel1.channel.cid }),
- channelsQueryState: channelsQueryStateMock,
- client: chatClient,
- searchController: new SearchController(),
- setActiveChannel,
- })}
- >
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- setActiveChannel.mockClear();
-
- act(() => dispatchChannelHiddenEvent(chatClient, testChannel1.channel));
-
- await waitFor(() => {
- expect(setActiveChannel).toHaveBeenCalledTimes(1);
- expect(setActiveChannel).toHaveBeenCalledWith();
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
- });
-
- describe('channel.visible', () => {
- const channelListProps = {
- filters: {},
-
- options: {
- limit: 25,
- message_limit: 25,
- presence: true,
- state: true,
- watch: true,
- },
- };
-
- beforeEach(async () => {
- chatClient = await getTestClientWithUser({ id: 'jaap' });
- useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
- });
-
- it('should move channel to top of the list by default', async () => {
- const { container, getAllByRole, getByRole, getByTestId } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- useMockedApis(chatClient, [getOrCreateChannelApi(testChannel3)]);
-
- act(() => dispatchChannelVisibleEvent(chatClient, testChannel3.channel));
-
- await waitFor(() => {
- expect(getByTestId(testChannel3.channel.id)).toBeInTheDocument();
- });
-
- const items = getAllByRole('listitem');
-
- // Get the closes listitem to the channel that received new message.
- const channelPreview = getByTestId(testChannel3.channel.id);
- expect(channelPreview.isEqualNode(items[0])).toBe(true);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call `onChannelVisible` function prop, if provided', async () => {
- const onChannelVisible = vi.fn();
- const { container, getByRole } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- act(() => dispatchChannelVisibleEvent(chatClient, testChannel3.channel));
-
- await waitFor(() => {
- expect(onChannelVisible).toHaveBeenCalledTimes(1);
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
- });
-
- describe('connection.recovered', () => {
- it('should rerender the list', async () => {
- const channel1 = generateChannel();
- const channel2 = generateChannel();
- const channelListProps = {
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- };
-
- useMockedApis(chatClient, [queryChannelsApi([channel1])]);
-
- const { container, getByRole, getByTestId } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- const updateCount = parseInt(getByTestId('channelUpdateCount').textContent, 10);
-
- useMockedApis(chatClient, [queryChannelsApi([channel2])]);
- act(() => dispatchConnectionRecoveredEvent(chatClient));
-
- await waitFor(() => {
- expect(parseInt(getByTestId('channelUpdateCount').textContent, 10)).toBe(
- updateCount + 1,
- );
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
- });
-
- describe('channel.truncated', () => {
- let channel1: ChannelAPIResponse;
- let user1: UserResponse;
- let message1: LocalMessage;
- let message2: LocalMessage;
-
- const channelListProps = {
- filters: {},
-
- options: { limit: 25, message_limit: 25 },
- };
-
- beforeEach(() => {
- user1 = generateUser();
- message1 = generateMessage({ user: user1 });
- message2 = generateMessage({ user: user1 });
- channel1 = generateChannel({ messages: [message1, message2] });
-
- useMockedApis(chatClient, [queryChannelsApi([channel1])]);
- });
-
- it('should call `onChannelTruncated` function prop, if provided', async () => {
- const onChannelTruncated = vi.fn();
- const { container, getByRole } = await render(
-
-
-
-
- ,
- );
-
- // Wait for list of channels to load in DOM.
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- act(() => dispatchChannelTruncatedEvent(chatClient, channel1.channel));
-
- await waitFor(() => {
- expect(onChannelTruncated).toHaveBeenCalledTimes(1);
- });
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
- });
-
- describe('predefined_filter response metadata', () => {
- // Resolved `predefined_filter` from queryChannels response overrides the
- // caller-supplied `filters`/`sort` props for local WS-driven channel list
- // mutation decisions. See stream-chat PR #1747 for the underlying SDK
- // change.
-
- const mockQueryChannelsResponseWithPredefinedFilter = ({
- channels,
- filter,
- sort,
- }: {
- channels: ChannelAPIResponse[];
- filter: Record;
- sort?: { direction?: 1 | -1; field: string }[];
- }) => {
- (vi.spyOn(chatClient.axiosInstance, 'post') as unknown as Mock).mockResolvedValue(
- {
- data: {
- channels,
- duration: '0.01ms',
- predefined_filter: {
- filter,
- name: 'messaging_channels',
- sort,
- },
- },
- status: 200,
- },
- );
- };
-
- const channelListProps = {
- filters: {},
- options: {
- limit: 25,
- message_limit: 25,
- predefined_filter: 'messaging_channels',
- },
- };
-
- it('does not promote an archived channel when resolved filter excludes archived', async () => {
- mockQueryChannelsResponseWithPredefinedFilter({
- channels: [testChannel1, testChannel2, testChannel3],
- filter: { archived: false },
- });
-
- const { getAllByRole, getByRole } = await render(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- // Mark target channel as archived after it has been loaded into the
- // list so the response is still "non-archived" but local state for the
- // channel is archived.
- const targetChannel = chatClient.activeChannels[testChannel3.channel.cid];
- targetChannel.state.membership = {
- archived_at: '2024-01-15T10:30:00Z',
- };
-
- const itemsBefore = getAllByRole('listitem').map((el) =>
- el.getAttribute('data-testid'),
- );
-
- await act(() => {
- dispatchMessageNewEvent(
- chatClient,
- generateMessage({ user: generateUser() }),
- testChannel3.channel,
- );
- });
-
- const itemsAfter = getAllByRole('listitem').map((el) =>
- el.getAttribute('data-testid'),
- );
-
- expect(itemsAfter).toStrictEqual(itemsBefore);
- });
-
- it('does not move a pinned channel when resolved sort considers pinned_at', async () => {
- mockQueryChannelsResponseWithPredefinedFilter({
- channels: [testChannel1, testChannel2, testChannel3],
- filter: {},
- sort: [{ direction: -1, field: 'pinned_at' }],
- });
-
- const { getAllByRole, getByRole } = await render(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- // Mark target channel as pinned in local state after it has been
- // loaded.
- const targetChannel = chatClient.activeChannels[testChannel3.channel.cid];
- targetChannel.state.membership = {
- pinned_at: '2024-01-15T10:30:00Z',
- };
-
- const itemsBefore = getAllByRole('listitem').map((el) =>
- el.getAttribute('data-testid'),
- );
-
- await act(() => {
- dispatchMessageNewEvent(
- chatClient,
- generateMessage({ user: generateUser() }),
- testChannel3.channel,
- );
- });
-
- const itemsAfter = getAllByRole('listitem').map((el) =>
- el.getAttribute('data-testid'),
- );
-
- expect(itemsAfter).toStrictEqual(itemsBefore);
- });
-
- it('falls back to caller filters/sort when response has no predefined_filter', async () => {
- useMockedApis(chatClient, [
- queryChannelsApi([testChannel1, testChannel2, testChannel3]),
- ]);
-
- const { getAllByRole, getByRole, getByText } = await render(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(getByRole('list')).toBeInTheDocument();
- });
-
- // Even with archived local membership, the absence of an effective
- // `archived` filter means the channel should still be promoted.
- const targetChannel = chatClient.activeChannels[testChannel3.channel.cid];
- targetChannel.state.membership = {
- archived_at: '2024-01-15T10:30:00Z',
- };
-
- const newMessage = generateMessage({ user: generateUser() });
- await act(() => {
- dispatchMessageNewEvent(chatClient, newMessage, testChannel3.channel);
- });
-
- await waitFor(() => {
- expect(getByText(newMessage.text)).toBeInTheDocument();
- });
-
- const items = getAllByRole('listitem');
- const channelPreview = getByText(newMessage.text).closest(
- ROLE_LIST_ITEM_SELECTOR,
- );
- expect(channelPreview?.isEqualNode(items[0])).toBe(true);
- });
- });
- });
-
- describe('on connection recovery', () => {
- const RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS = 5000;
- let queryChannelsMock: Mock;
-
- beforeEach(() => {
- chatClient.recoverStateOnReconnect = false;
- queryChannelsMock = vi.spyOn(chatClient, 'queryChannels');
- });
-
- afterEach(() => {
- queryChannelsMock.mockClear();
- });
-
- const renderUI = (client: StreamChat, channelListProps?: Partial) =>
- render(
-
-
- ,
- );
-
- it('should not reload the channels on connection.recovered if client state recovery is enabled (default)', async () => {
- chatClient.recoverStateOnReconnect = true;
- renderUI(chatClient);
-
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(1);
- await act(() => {
- chatClient.dispatchEvent({
- type: 'connection.recovered',
- });
- });
-
- await waitFor(() => {
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(1);
- });
- });
-
- it('should reload the channels on connection.recovered', async () => {
- renderUI(chatClient);
-
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(1);
-
- await act(() => {
- chatClient.dispatchEvent({
- type: 'connection.recovered',
- });
- });
-
- await waitFor(() => {
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(2);
- expect(vi.mocked(chatClient.queryChannels).mock.calls[1][2]).toStrictEqual(
- expect.objectContaining({ offset: 0 }),
- );
- });
- });
-
- it('should execute recovery queries outside throttle interval', async () => {
- renderUI(chatClient);
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(1);
-
- const dateNowSpy = vi
- .spyOn(Date, 'now')
- .mockReturnValueOnce(1)
- .mockReturnValueOnce(RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS + 1);
-
- await act(async () => {
- await chatClient.dispatchEvent({
- type: 'connection.recovered',
- });
- });
-
- await waitFor(() => {
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(2);
- expect(vi.mocked(chatClient.queryChannels).mock.calls[1][2]).toStrictEqual(
- expect.objectContaining({ offset: 0 }),
- );
- });
-
- await act(async () => {
- await chatClient.dispatchEvent({
- type: 'connection.recovered',
- });
- });
-
- await waitFor(() => {
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(3);
- expect(vi.mocked(chatClient.queryChannels).mock.calls[2][2]).toStrictEqual(
- expect.objectContaining({ offset: 0 }),
- );
- });
-
- dateNowSpy.mockRestore();
- });
-
- it('should circumvent the throttle interval if the last query failed', async () => {
- renderUI(chatClient);
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(1);
-
- vi.spyOn(console, 'warn').mockImplementationOnce(() => null);
- queryChannelsMock.mockRejectedValueOnce(new Error());
- const dateNowSpy = vi
- .spyOn(Date, 'now')
- .mockReturnValueOnce(1)
- .mockReturnValueOnce(RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS);
-
- await act(() => {
- chatClient.dispatchEvent({
- type: 'connection.recovered',
- });
- });
-
- await waitFor(() => {
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(2);
- });
-
- await act(() => {
- chatClient.dispatchEvent({
- type: 'connection.recovered',
- });
- });
-
- await waitFor(() => {
- expect(chatClient.queryChannels).toHaveBeenCalledTimes(3);
- expect(vi.mocked(chatClient.queryChannels).mock.calls[2][2]).toStrictEqual(
- expect.objectContaining({ offset: 0 }),
- );
- });
-
- dateNowSpy.mockRestore();
- });
- });
-
- describe('context', () => {
- it('allows to set the new list of channels', async () => {
- let setChannelsFromOutside: ChannelListContextValue['setChannels'];
- const channelsToBeLoaded = Array.from({ length: 5 }, generateChannel);
- const channelsToBeSet = Array.from({ length: 5 }, generateChannel);
- const channelsToIdString = (channels) => channels.map(({ id }) => id).join();
- const channelsDataToIdString = (channels) =>
- channels.map(({ channel: { id } }) => id).join();
-
- const ChannelListCustom = () => {
- const { channels, setChannels } = useChannelListContext();
- useEffect(() => {
- setChannelsFromOutside = setChannels;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
- return {channelsToIdString(channels)}
;
- };
- const props = {
- filters: {},
- };
-
- useMockedApis(chatClient, [queryChannelsApi(channelsToBeLoaded)]);
-
- await render(
-
-
-
-
- ,
- );
-
- await waitFor(() => {
- expect(
- screen.getByText(channelsDataToIdString(channelsToBeLoaded)),
- ).toBeInTheDocument();
- });
-
- await act(() => {
- setChannelsFromOutside(chatClient.hydrateActiveChannels(channelsToBeSet));
- });
-
- await waitFor(() => {
- expect(
- screen.queryByText(channelsDataToIdString(channelsToBeLoaded)),
- ).not.toBeInTheDocument();
- expect(
- screen.getByText(channelsDataToIdString(channelsToBeSet)),
- ).toBeInTheDocument();
- });
- });
- });
-});
diff --git a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx
index a0c9f9ab26..1f46c7f9c1 100644
--- a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx
+++ b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx
@@ -1,10 +1,17 @@
import React from 'react';
import { cleanup, render, screen } from '@testing-library/react';
-import { ChatProvider, WithComponents } from '../../../context';
+import { WithComponents } from '../../../context';
import { TranslationProvider } from '../../../context/TranslationContext';
-import { mockChatContext, mockTranslationContextValue } from '../../../mock-builders';
+import { mockTranslationContextValue } from '../../../mock-builders';
import { ChannelListHeader } from '../ChannelListHeader';
+// The header derives "a channel is active" from the ChatView slot bindings
+// (useSlotChannels), not ChatContext.channel. Mock it to control activeness.
+const mockUseSlotChannels = vi.fn();
+vi.mock('../../ChatView', () => ({
+ useSlotChannels: () => mockUseSlotChannels(),
+}));
+
const t = vi.fn((key: string) => key);
const HeaderEndContent = () => ;
@@ -12,25 +19,23 @@ afterEach(cleanup);
describe('ChannelListHeader', () => {
it('should not render HeaderEndContent when not provided via ComponentContext', () => {
+ mockUseSlotChannels.mockReturnValue([{ channel: {}, slot: 'slot1' }]);
render(
-
-
-
-
- ,
+
+
+ ,
);
expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument();
});
it('should render HeaderEndContent when a channel is active', () => {
+ mockUseSlotChannels.mockReturnValue([{ channel: {}, slot: 'slot1' }]);
render(
-
-
-
-
-
+
+
+
,
);
@@ -38,13 +43,12 @@ describe('ChannelListHeader', () => {
});
it('should not render HeaderEndContent when no channel is active', () => {
+ mockUseSlotChannels.mockReturnValue([]);
render(
-
-
-
-
-
+
+
+
,
);
diff --git a/src/components/ChannelList/__tests__/ChannelListUI.test.tsx b/src/components/ChannelList/__tests__/ChannelListUI.test.tsx
deleted file mode 100644
index 1ddf79c08a..0000000000
--- a/src/components/ChannelList/__tests__/ChannelListUI.test.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import React from 'react';
-import { cleanup, render } from '@testing-library/react';
-import { fromPartial } from '@total-typescript/shoehorn';
-
-import { ChannelListUI } from '../ChannelListUI';
-import type { ChannelListUIProps } from '../ChannelListUI';
-import { ComponentProvider, TranslationProvider } from '../../../context';
-import { mockTranslationContextValue } from '../../../mock-builders';
-
-// Weird hack to avoid big warnings
-// Maybe better to find a better solution for it.
-console.warn = () => null;
-
-const Component = ({ error = false, loading = false }: any) => (
-
- Loading Error Indicator
,
- LoadingIndicator: () => Loading Indicator
,
- }}
- >
- ({
- error,
- loading,
- })}
- >
- children 1
- children 2
-
-
-
-);
-
-describe('ChannelListMessenger', () => {
- afterEach(cleanup);
-
- it('by default, children should be rendered', () => {
- const { container } = render();
- expect(container).toMatchSnapshot();
- });
- it('when `error` prop is true, `LoadingErrorIndicator` should be rendered', () => {
- const { container } = render();
- expect(container).toMatchSnapshot();
- });
- it('when `loading` prop is true, `LoadingIndicator` should be rendered', () => {
- const { container } = render();
- expect(container).toMatchSnapshot();
- });
-});
diff --git a/src/components/ChannelList/__tests__/__snapshots__/ChannelListUI.test.tsx.snap b/src/components/ChannelList/__tests__/__snapshots__/ChannelListUI.test.tsx.snap
deleted file mode 100644
index af404ab948..0000000000
--- a/src/components/ChannelList/__tests__/__snapshots__/ChannelListUI.test.tsx.snap
+++ /dev/null
@@ -1,48 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`ChannelListMessenger > by default, children should be rendered 1`] = `
-
-
-
-
- children 1
-
-
- children 2
-
-
-
-
-`;
-
-exports[`ChannelListMessenger > when \`error\` prop is true, \`LoadingErrorIndicator\` should be rendered 1`] = `
-
-
- Loading Error Indicator
-
-
-`;
-
-exports[`ChannelListMessenger > when \`loading\` prop is true, \`LoadingIndicator\` should be rendered 1`] = `
-
-
-
-
- Loading Indicator
-
-
-
-
-`;
diff --git a/src/components/ChannelList/hooks/index.ts b/src/components/ChannelList/hooks/index.ts
index 421def9a09..60db751d70 100644
--- a/src/components/ChannelList/hooks/index.ts
+++ b/src/components/ChannelList/hooks/index.ts
@@ -1,4 +1,2 @@
-export * from './useConnectionRecoveredListener';
-export * from './usePaginatedChannels';
export * from './useChannelMembershipState';
export * from './useChannelMembersState';
diff --git a/src/components/ChannelList/hooks/useChannelListShape.ts b/src/components/ChannelList/hooks/useChannelListShape.ts
deleted file mode 100644
index fa5feb7f94..0000000000
--- a/src/components/ChannelList/hooks/useChannelListShape.ts
+++ /dev/null
@@ -1,651 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef } from 'react';
-import type { Channel, Event } from 'stream-chat';
-import type { Dispatch, SetStateAction } from 'react';
-
-import {
- extractSortValue,
- findLastPinnedChannelIndex,
- isChannelArchived,
- isChannelPinned,
- moveChannelUpwards,
- shouldConsiderArchivedChannels,
- shouldConsiderPinnedChannels,
-} from '../utils';
-import { useChatContext } from '../../../context';
-import { getChannel } from '../../../utils';
-import type { ChannelListProps } from '../ChannelList';
-
-type SetChannels = Dispatch>;
-
-type BaseParameters = {
- event: Event;
- setChannels: SetChannels;
-};
-
-type RepeatedParameters = {
- customHandler?: (
- setChannels: BaseParameters['setChannels'],
- event: BaseParameters['event'],
- ) => void;
-};
-
-type HandleMessageNewParameters = BaseParameters &
- RepeatedParameters & {
- allowNewMessagesFromUnfilteredChannels: boolean;
- lockChannelOrder: boolean;
- } & Required>;
-
-type HandleNotificationMessageNewParameters = BaseParameters &
- RepeatedParameters & {
- allowNewMessagesFromUnfilteredChannels: boolean;
- lockChannelOrder: boolean;
- } & Required>;
-
-type HandleNotificationRemovedFromChannelParameters = BaseParameters & RepeatedParameters;
-
-type HandleNotificationAddedToChannelParameters = BaseParameters &
- RepeatedParameters & {
- allowNewMessagesFromUnfilteredChannels: boolean;
- lockChannelOrder: boolean;
- } & Required>;
-
-type HandleChannelVisibleParameters = BaseParameters &
- RepeatedParameters &
- Required>;
-
-type HandleMemberUpdatedParameters = BaseParameters & {
- lockChannelOrder: boolean;
-} & Required>;
-
-type HandleChannelDeletedParameters = BaseParameters & RepeatedParameters;
-
-type HandleChannelHiddenParameters = BaseParameters & RepeatedParameters;
-
-type HandleChannelTruncatedParameters = BaseParameters & RepeatedParameters;
-
-type HandleChannelUpdatedParameters = BaseParameters & RepeatedParameters;
-
-type HandleUserPresenceChangedParameters = BaseParameters;
-
-const shared = ({
- customHandler,
- event,
- setChannels,
-}: BaseParameters & RepeatedParameters) => {
- if (typeof customHandler === 'function') {
- return customHandler(setChannels, event);
- }
-
- setChannels((channels) => {
- const channelIndex = channels.findIndex((channel) => channel.cid === event.cid);
-
- if (channelIndex < 0) return channels;
-
- channels.splice(channelIndex, 1);
-
- return [...channels];
- });
-};
-
-export const useChannelListShapeDefaults = () => {
- const { client } = useChatContext();
-
- const handleMessageNew = useCallback(
- ({
- allowNewMessagesFromUnfilteredChannels,
- customHandler,
- event,
- filters,
- lockChannelOrder,
- setChannels,
- sort,
- }: HandleMessageNewParameters) => {
- if (typeof customHandler === 'function') {
- return customHandler(setChannels, event);
- }
-
- const channelType = event.channel_type;
- const channelId = event.channel_id;
-
- if (!channelType || !channelId) return;
-
- setChannels((currentChannels) => {
- const targetChannel = client.channel(channelType, channelId);
- const targetChannelIndex = currentChannels.indexOf(targetChannel);
- const targetChannelExistsWithinList = targetChannelIndex >= 0;
-
- const isTargetChannelPinned = isChannelPinned(targetChannel);
- const isTargetChannelArchived = isChannelArchived(targetChannel);
-
- const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
- const considerPinnedChannels = shouldConsiderPinnedChannels(sort);
-
- if (
- // filter is defined, target channel is archived and filter option is set to false
- (considerArchivedChannels && isTargetChannelArchived && !filters.archived) ||
- // filter is defined, target channel isn't archived and filter option is set to true
- (considerArchivedChannels && !isTargetChannelArchived && filters.archived) ||
- // sort option is defined, target channel is pinned
- (considerPinnedChannels && isTargetChannelPinned) ||
- // list order is locked
- lockChannelOrder ||
- // target channel is not within the loaded list and loading from cache is disallowed
- (!targetChannelExistsWithinList && !allowNewMessagesFromUnfilteredChannels)
- ) {
- return currentChannels;
- }
-
- return moveChannelUpwards({
- channels: currentChannels,
- channelToMove: targetChannel,
- channelToMoveIndexWithinChannels: targetChannelIndex,
- sort,
- });
- });
- },
- [client],
- );
-
- const handleNotificationMessageNew = useCallback(
- async ({
- allowNewMessagesFromUnfilteredChannels,
- customHandler,
- event,
- filters,
- setChannels,
- sort,
- }: HandleNotificationMessageNewParameters) => {
- if (typeof customHandler === 'function') {
- return customHandler(setChannels, event);
- }
-
- if (!event.channel) {
- return;
- }
-
- // Bail out before querying the channel: if new messages from unfiltered
- // channels are not allowed, this handler would discard the result anyway.
- // Querying first issued a `channel.watch()` (queryChannel) for every
- // `notification.message_new`, which could exhaust the rate limit when many
- // such events arrive at once (#2441).
- if (!allowNewMessagesFromUnfilteredChannels) {
- return;
- }
-
- const channel = await getChannel({
- client,
- id: event.channel.id,
- type: event.channel.type,
- });
-
- const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
- if (isChannelArchived(channel) && considerArchivedChannels && !filters.archived) {
- return;
- }
-
- setChannels((channels) =>
- moveChannelUpwards({
- channels,
- channelToMove: channel,
- sort,
- }),
- );
- },
- [client],
- );
-
- const handleNotificationAddedToChannel = useCallback(
- async ({
- allowNewMessagesFromUnfilteredChannels,
- customHandler,
- event,
- setChannels,
- sort,
- }: HandleNotificationAddedToChannelParameters) => {
- if (typeof customHandler === 'function') {
- return customHandler(setChannels, event);
- }
-
- if (!event.channel || !allowNewMessagesFromUnfilteredChannels) {
- return;
- }
-
- const channel = await getChannel({
- client,
- id: event.channel.id,
- members: event.channel.members?.reduce(
- (newMembers, { user, user_id }) => {
- const userId = user_id || user?.id;
-
- if (userId) newMembers.push(userId);
-
- return newMembers;
- },
- [],
- ),
- type: event.channel.type,
- });
-
- // membership has been reset (target channel shouldn't be pinned nor archived)
- setChannels((channels) =>
- moveChannelUpwards({
- channels,
- channelToMove: channel,
- sort,
- }),
- );
- },
- [client],
- );
-
- const handleNotificationRemovedFromChannel = useCallback(
- ({
- customHandler,
- event,
- setChannels,
- }: HandleNotificationRemovedFromChannelParameters) => {
- if (typeof customHandler === 'function') {
- return customHandler(setChannels, event);
- }
-
- setChannels((channels) =>
- channels.filter((channel) => channel.cid !== event.channel?.cid),
- );
- },
- [],
- );
-
- const handleMemberUpdated = useCallback(
- ({
- event,
- filters,
- lockChannelOrder,
- setChannels,
- sort,
- }: HandleMemberUpdatedParameters) => {
- if (
- !event.member?.user ||
- event.member.user.id !== client.userID ||
- !event.channel_type
- ) {
- return;
- }
-
- const channelType = event.channel_type;
- const channelId = event.channel_id;
-
- const considerPinnedChannels = shouldConsiderPinnedChannels(sort);
- const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
-
- // `pinned_at` nor `archived` properties are set or channel list order is locked, return early
- if ((!considerPinnedChannels && !considerArchivedChannels) || lockChannelOrder) {
- return;
- }
-
- const pinnedAtSort = extractSortValue({ atIndex: 0, sort, targetKey: 'pinned_at' });
-
- setChannels((currentChannels) => {
- const targetChannel = client.channel(channelType, channelId);
- // assumes that channel instances are not changing
- const targetChannelIndex = currentChannels.indexOf(targetChannel);
- const targetChannelExistsWithinList = targetChannelIndex >= 0;
-
- const isTargetChannelArchived = isChannelArchived(targetChannel);
- const isTargetChannelPinned = isChannelPinned(targetChannel);
-
- const newChannels = [...currentChannels];
-
- if (targetChannelExistsWithinList) {
- newChannels.splice(targetChannelIndex, 1);
- }
-
- // handle archiving (remove channel)
- if (
- (considerArchivedChannels && isTargetChannelArchived && !filters.archived) ||
- (considerArchivedChannels && !isTargetChannelArchived && filters.archived)
- ) {
- return newChannels;
- }
-
- let lastPinnedChannelIndex: number | null = null;
-
- // calculate last pinned channel index only if `pinned_at` sort is set to
- // ascending order or if it's in descending order while the pin is being removed, otherwise
- // we move to the top (index 0)
- if (pinnedAtSort === 1 || (pinnedAtSort === -1 && !isTargetChannelPinned)) {
- lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels });
- }
-
- const newTargetChannelIndex =
- typeof lastPinnedChannelIndex === 'number' ? lastPinnedChannelIndex + 1 : 0;
-
- newChannels.splice(newTargetChannelIndex, 0, targetChannel);
-
- return newChannels;
- });
- },
- [client],
- );
-
- const handleChannelDeleted = useCallback(
- (p: HandleChannelDeletedParameters) => shared(p),
- [],
- );
-
- const handleChannelHidden = useCallback(
- (p: HandleChannelHiddenParameters) => shared(p),
- [],
- );
-
- const handleChannelVisible = useCallback(
- async ({
- customHandler,
- event,
- filters,
- setChannels,
- sort,
- }: HandleChannelVisibleParameters) => {
- if (typeof customHandler === 'function') {
- return customHandler(setChannels, event);
- }
-
- if (!event.channel_id && !event.channel_type) {
- return;
- }
-
- const channel = await getChannel({
- client,
- id: event.channel_id,
- type: event.channel_type,
- });
-
- const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
- if (isChannelArchived(channel) && considerArchivedChannels && !filters.archived) {
- return;
- }
-
- setChannels((channels) =>
- moveChannelUpwards({
- channels,
- channelToMove: channel,
- sort,
- }),
- );
- },
- [client],
- );
-
- const handleChannelTruncated = useCallback(
- ({ customHandler, event, setChannels }: HandleChannelTruncatedParameters) => {
- if (typeof customHandler === 'function') {
- return customHandler(setChannels, event);
- }
-
- // TODO: not sure whether this is needed
- setChannels((channels) => [...channels]);
- // if (forceUpdate) {
- // forceUpdate();
- // }
- },
- [],
- );
-
- const handleChannelUpdated = useCallback(
- ({ customHandler, event, setChannels }: HandleChannelUpdatedParameters) => {
- if (typeof customHandler === 'function') {
- return customHandler(setChannels, event);
- }
-
- setChannels((channels) => {
- const channelIndex = channels.findIndex(
- (channel) => channel.cid === event.channel?.cid,
- );
-
- if (channelIndex > -1 && event.channel) {
- const newChannels = channels;
- newChannels[channelIndex].data = {
- ...event.channel,
- hidden: event.channel?.hidden ?? newChannels[channelIndex].data?.hidden,
- own_capabilities:
- event.channel?.own_capabilities ??
- newChannels[channelIndex].data?.own_capabilities,
- };
-
- return [...newChannels];
- }
-
- return channels;
- });
-
- // if (forceUpdate) {
- // forceUpdate();
- // }
- },
- [],
- );
-
- const handleUserPresenceChanged = useCallback(
- ({ event, setChannels }: HandleUserPresenceChangedParameters) => {
- setChannels((channels) => {
- const newChannels = channels.map((channel) => {
- if (!event.user?.id || !channel.state.members[event.user.id]) {
- return channel;
- }
-
- // FIXME: oh no...
- const newChannel = channel;
- newChannel.state.members[event.user.id].user = event.user;
-
- return newChannel;
- });
-
- return newChannels;
- });
- },
- [],
- );
-
- return useMemo(
- () => ({
- handleChannelDeleted,
- handleChannelHidden,
- handleChannelTruncated,
- handleChannelUpdated,
- handleChannelVisible,
- handleMemberUpdated,
- handleMessageNew,
- handleNotificationAddedToChannel,
- handleNotificationMessageNew,
- handleNotificationRemovedFromChannel,
- handleUserPresenceChanged,
- }),
- [
- handleChannelDeleted,
- handleChannelHidden,
- handleChannelTruncated,
- handleChannelUpdated,
- handleChannelVisible,
- handleMemberUpdated,
- handleMessageNew,
- handleNotificationAddedToChannel,
- handleNotificationMessageNew,
- handleNotificationRemovedFromChannel,
- handleUserPresenceChanged,
- ],
- );
-};
-
-type UseDefaultHandleChannelListShapeParameters = Required<
- Pick<
- ChannelListProps,
- 'allowNewMessagesFromUnfilteredChannels' | 'lockChannelOrder' | 'filters' | 'sort'
- >
-> &
- Pick<
- ChannelListProps,
- | 'onAddedToChannel'
- | 'onChannelDeleted'
- | 'onChannelHidden'
- | 'onChannelTruncated'
- | 'onChannelUpdated'
- | 'onChannelVisible'
- | 'onMessageNew'
- | 'onMessageNewHandler'
- | 'onRemovedFromChannel'
- > & {
- setChannels: SetChannels;
- customHandleChannelListShape?: (data: {
- defaults: ReturnType;
- event: Event;
- setChannels: SetChannels;
- }) => void;
- };
-
-export const usePrepareShapeHandlers = ({
- allowNewMessagesFromUnfilteredChannels,
- customHandleChannelListShape,
- filters,
- lockChannelOrder,
- onAddedToChannel,
- onChannelDeleted,
- onChannelHidden,
- onChannelTruncated,
- onChannelUpdated,
- onChannelVisible,
- onMessageNew,
- onMessageNewHandler,
- onRemovedFromChannel,
- setChannels,
- sort,
-}: UseDefaultHandleChannelListShapeParameters) => {
- const defaults = useChannelListShapeDefaults();
-
- const defaultHandleChannelListShapeRef = useRef<(e: Event) => void>(undefined);
-
- const customHandleChannelListShapeRef = useRef<(e: Event) => void>(undefined);
-
- customHandleChannelListShapeRef.current = (event: Event) => {
- customHandleChannelListShape?.({ defaults, event, setChannels });
- };
-
- defaultHandleChannelListShapeRef.current = (event: Event) => {
- switch (event.type) {
- case 'message.new':
- defaults.handleMessageNew({
- allowNewMessagesFromUnfilteredChannels,
- customHandler: onMessageNewHandler,
- event,
- filters,
- lockChannelOrder,
- setChannels,
- sort,
- });
- break;
- case 'notification.message_new':
- defaults.handleNotificationMessageNew({
- allowNewMessagesFromUnfilteredChannels,
- customHandler: onMessageNew,
- event,
- filters,
- lockChannelOrder,
- setChannels,
- sort,
- });
- break;
- case 'notification.added_to_channel':
- defaults.handleNotificationAddedToChannel({
- allowNewMessagesFromUnfilteredChannels,
- customHandler: onAddedToChannel,
- event,
- lockChannelOrder,
- setChannels,
- sort,
- });
- break;
- case 'notification.removed_from_channel':
- defaults.handleNotificationRemovedFromChannel({
- customHandler: onRemovedFromChannel,
- event,
- setChannels,
- });
- break;
- case 'channel.deleted':
- defaults.handleChannelDeleted({
- customHandler: onChannelDeleted,
- event,
- setChannels,
- });
- break;
- case 'channel.hidden':
- defaults.handleChannelHidden({
- customHandler: onChannelHidden,
- event,
- setChannels,
- });
- break;
- case 'channel.visible':
- defaults.handleChannelVisible({
- customHandler: onChannelVisible,
- event,
- filters,
- setChannels,
- sort,
- });
- break;
- case 'channel.truncated':
- defaults.handleChannelTruncated({
- customHandler: onChannelTruncated,
- event,
- setChannels,
- });
- break;
- case 'channel.updated':
- defaults.handleChannelUpdated({
- customHandler: onChannelUpdated,
- event,
- setChannels,
- });
- break;
- case 'user.presence.changed':
- defaults.handleUserPresenceChanged({ event, setChannels });
- break;
- case 'member.updated':
- defaults.handleMemberUpdated({
- event,
- filters,
- lockChannelOrder,
- setChannels,
- sort,
- });
- break;
- default:
- break;
- }
- };
-
- const defaultFn = useCallback((e: Event) => {
- defaultHandleChannelListShapeRef.current?.(e);
- }, []);
-
- const customFn = useMemo(() => {
- if (!customHandleChannelListShape) return null;
- return (e: Event) => {
- customHandleChannelListShapeRef.current?.(e);
- };
- }, [customHandleChannelListShape]);
-
- return {
- customHandler: customFn,
- defaultHandler: defaultFn,
- };
-};
-
-export const useChannelListShape = (handler: (e: Event) => void) => {
- const { client } = useChatContext();
-
- useEffect(() => {
- const subscription = client.on('all', handler);
-
- return subscription.unsubscribe;
- }, [client, handler]);
-};
diff --git a/src/components/ChannelList/hooks/useConnectionRecoveredListener.ts b/src/components/ChannelList/hooks/useConnectionRecoveredListener.ts
deleted file mode 100644
index 5a3138f935..0000000000
--- a/src/components/ChannelList/hooks/useConnectionRecoveredListener.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useEffect } from 'react';
-
-import { useChatContext } from '../../../context/ChatContext';
-
-export const useConnectionRecoveredListener = (forceUpdate?: () => void) => {
- const { client } = useChatContext('useConnectionRecoveredListener');
-
- useEffect(() => {
- const handleEvent = () => {
- if (forceUpdate) {
- forceUpdate();
- }
- };
-
- client.on('connection.recovered', handleEvent);
-
- return () => {
- client.off('connection.recovered', handleEvent);
- };
- }, [client, forceUpdate]);
-};
diff --git a/src/components/ChannelList/hooks/usePaginatedChannels.ts b/src/components/ChannelList/hooks/usePaginatedChannels.ts
deleted file mode 100644
index f62e46a01c..0000000000
--- a/src/components/ChannelList/hooks/usePaginatedChannels.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import uniqBy from 'lodash.uniqby';
-
-import type {
- APIErrorResponse,
- Channel,
- ChannelFilters,
- ChannelOptions,
- ChannelSort,
- ErrorFromResponse,
- ParsedPredefinedFilterResponse,
- StreamChat,
-} from 'stream-chat';
-
-import { useChatContext } from '../../../context/ChatContext';
-import { useTranslationContext } from '../../../context/TranslationContext';
-import { useNotificationApi } from '../../Notifications';
-
-import type { ChannelsQueryState } from '../../Chat/hooks/useChannelsQueryState';
-
-const RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS = 5000;
-const MIN_RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS = 2000;
-
-type AllowedQueryType = Extract<
- ChannelsQueryState['queryInProgress'],
- 'reload' | 'load-more'
->;
-
-export type CustomQueryChannelParams = {
- currentChannels: Array;
- queryType: AllowedQueryType;
- setChannels: React.Dispatch>>;
- setHasNextPage: React.Dispatch>;
-};
-
-export type CustomQueryChannelsFn = (params: CustomQueryChannelParams) => Promise;
-
-/**
- * Filters and sort effectively used by the channel list. When `options.predefined_filter`
- * is set, these reflect the backend-resolved values from `predefined_filter` response
- * metadata; otherwise they fall back to the caller-supplied `filters`/`sort`.
- */
-export type EffectiveQueryParams = {
- filters: ChannelFilters;
- sort: ChannelSort;
-};
-
-/**
- * The `predefined_filter` response carries sort as
- * `[{ field, direction }]`. `ChannelSort` expects `[{ field: direction }]`.
- */
-const mapPredefinedFilterSortToChannelSort = (
- sort: NonNullable,
-): ChannelSort =>
- sort.map(({ direction = 1, field }) => ({
- [field]: direction,
- })) as ChannelSort;
-
-export const usePaginatedChannels = (
- client: StreamChat,
- filters: ChannelFilters,
- sort: ChannelSort,
- options: ChannelOptions,
- activeChannelHandler: (
- channels: Array,
- setChannels: React.Dispatch>>,
- effectiveQueryParams: EffectiveQueryParams,
- ) => void,
- recoveryThrottleIntervalMs: number = RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS,
- customQueryChannels?: CustomQueryChannelsFn,
-) => {
- const { addNotification } = useNotificationApi();
- const {
- channelsQueryState: { error, setError, setQueryInProgress },
- } = useChatContext('usePaginatedChannels');
- const { t } = useTranslationContext();
- const [channels, setChannels] = useState>([]);
- const [hasNextPage, setHasNextPage] = useState(true);
- // Backend-resolved filter/sort from `predefined_filter` response metadata.
- // Used to override the caller `filters`/`sort` for local WS-driven list
- // mutation decisions (archiving, pinning) when a predefined filter is in
- // use. Stays `undefined` for non-predefined queries.
- const [responseFilters, setResponseFilters] = useState(
- undefined,
- );
- const [responseSort, setResponseSort] = useState(undefined);
- const lastRecoveryTimestamp = useRef(undefined);
-
- const recoveryThrottleInterval =
- recoveryThrottleIntervalMs < MIN_RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS
- ? MIN_RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS
- : (recoveryThrottleIntervalMs ?? RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS);
- // memoize props
- const filterString = useMemo(() => JSON.stringify(filters), [filters]);
- const sortString = useMemo(() => JSON.stringify(sort), [sort]);
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- const queryChannels = async (queryType = 'load-more') => {
- setError(null);
- const offset = queryType === 'reload' ? 0 : channels.length;
- const isFirstPage = offset === 0;
-
- if (queryType === 'reload') {
- setChannels([]);
- }
- setQueryInProgress(queryType as AllowedQueryType);
-
- try {
- if (customQueryChannels) {
- await customQueryChannels({
- currentChannels: channels,
- queryType: queryType as AllowedQueryType,
- setChannels,
- setHasNextPage,
- });
- // `customQueryChannels` bypasses the SDK response so any previously
- // resolved predefined-filter metadata is no longer trustworthy.
- setResponseFilters(undefined);
- setResponseSort(undefined);
- } else {
- const newOptions = {
- offset,
- ...options,
- };
-
- const channelQueryResponse = await client.queryChannels(
- filters,
- sort || {},
- newOptions,
- { withResponse: true },
- );
-
- const newChannels =
- queryType === 'reload'
- ? channelQueryResponse.channels
- : uniqBy([...channels, ...channelQueryResponse.channels], 'cid');
-
- setChannels(newChannels);
- setHasNextPage(channelQueryResponse.channels.length >= (newOptions.limit ?? 1));
-
- // Pull backend-resolved filter/sort from `predefined_filter` metadata so
- // WS-driven list mutations use the effective semantics. Always reset
- // first; non-predefined queries do not return this metadata and keeping
- // stale values would silently change list behavior.
- const predefinedFilter = channelQueryResponse.predefined_filter;
- const nextResponseFilters = predefinedFilter
- ? (predefinedFilter.filter as ChannelFilters)
- : undefined;
- const nextResponseSort = predefinedFilter?.sort
- ? mapPredefinedFilterSortToChannelSort(predefinedFilter.sort)
- : undefined;
-
- setResponseFilters(nextResponseFilters);
- setResponseSort(nextResponseSort);
-
- // Set active channel only on load of first page
- if (!offset && activeChannelHandler) {
- activeChannelHandler(newChannels, setChannels, {
- filters: nextResponseFilters ?? filters,
- sort: nextResponseSort ?? sort,
- });
- }
- }
- } catch (error) {
- console.warn(error);
- addNotification({
- emitter: 'ChannelList',
- error: error instanceof Error ? error : undefined,
- message: isFirstPage
- ? t('Failed to load channels')
- : t('Failed to load more channels'),
- severity: 'error',
- targetPanels: ['channel-list'],
- type: isFirstPage
- ? 'api:channel-list:load:failed'
- : 'api:channel-list:load-more:failed',
- });
-
- if (isFirstPage) {
- setError(error as ErrorFromResponse);
- }
- }
-
- setQueryInProgress(null);
- };
-
- const throttleRecover = useCallback(() => {
- const now = Date.now();
- const isFirstRecovery = !lastRecoveryTimestamp.current;
- const timeElapsedSinceLastRecoveryMs = lastRecoveryTimestamp.current
- ? now - lastRecoveryTimestamp.current
- : 0;
-
- if (
- !isFirstRecovery &&
- timeElapsedSinceLastRecoveryMs < recoveryThrottleInterval &&
- !error
- ) {
- return;
- }
-
- lastRecoveryTimestamp.current = now;
- queryChannels('reload');
- }, [error, queryChannels, recoveryThrottleInterval]);
-
- const loadNextPage = () => queryChannels();
-
- useEffect(() => {
- if (client.recoverStateOnReconnect) return;
- const { unsubscribe } = client.on('connection.recovered', throttleRecover);
-
- return () => {
- unsubscribe();
- };
- }, [client, throttleRecover]);
-
- useEffect(() => {
- queryChannels('reload');
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [filterString, sortString]);
-
- // Effective filters/sort: response-derived values take precedence over
- // caller-supplied props when a predefined filter is in use. Falls back to
- // the caller props for non-predefined queries and during the initial load
- // before the first response.
- const effectiveFilters = responseFilters ?? filters;
- const effectiveSort = responseSort ?? sort;
-
- return {
- channels,
- effectiveFilters,
- effectiveSort,
- hasNextPage,
- loadNextPage,
- setChannels,
- };
-};
diff --git a/src/components/ChannelList/index.ts b/src/components/ChannelList/index.ts
index 2f9989bb1e..377d5da3a8 100644
--- a/src/components/ChannelList/index.ts
+++ b/src/components/ChannelList/index.ts
@@ -1,4 +1,5 @@
export * from './ChannelList';
-export * from './ChannelListUI';
+export * from './ChannelLists';
+export * from './ChannelListHeader';
+export * from './ChannelNavigation';
export * from './hooks';
-export * from './utils';
diff --git a/src/components/ChannelList/utils.ts b/src/components/ChannelList/utils.ts
deleted file mode 100644
index 3f1c5bde2b..0000000000
--- a/src/components/ChannelList/utils.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import type { Channel, ChannelSort, ChannelSortBase } from 'stream-chat';
-
-import type { ChannelListProps } from './ChannelList';
-
-/**
- * Expects channel array sorted by `{ pinned_at: -1 }`.
- *
- * TODO: add support for the `{ pinned_at: 1 }`
- */
-export function findLastPinnedChannelIndex({ channels }: { channels: Channel[] }) {
- let lastPinnedChannelIndex: number | null = null;
-
- for (const channel of channels) {
- if (!isChannelPinned(channel)) break;
-
- if (typeof lastPinnedChannelIndex === 'number') {
- lastPinnedChannelIndex++;
- } else {
- lastPinnedChannelIndex = 0;
- }
- }
-
- return lastPinnedChannelIndex;
-}
-
-type MoveChannelUpwardsParams = {
- channels: Array;
- channelToMove: Channel;
- sort: ChannelSort;
- /**
- * If the index of the channel within `channels` list which is being moved upwards
- * (`channelToMove`) is known, you can supply it to skip extra calculation.
- */
- channelToMoveIndexWithinChannels?: number;
-};
-
-export const moveChannelUpwards = ({
- channels,
- channelToMove,
- channelToMoveIndexWithinChannels,
- sort,
-}: MoveChannelUpwardsParams) => {
- // get index of channel to move up
- const targetChannelIndex =
- channelToMoveIndexWithinChannels ??
- channels.findIndex((channel) => channel.cid === channelToMove.cid);
-
- const targetChannelExistsWithinList = targetChannelIndex >= 0;
- const targetChannelAlreadyAtTheTop = targetChannelIndex === 0;
-
- // pinned channels should not move within the list based on recent activity, channels which
- // receive messages and are not pinned should move upwards but only under the last pinned channel
- // in the list
- const considerPinnedChannels = shouldConsiderPinnedChannels(sort);
- const isTargetChannelPinned = isChannelPinned(channelToMove);
-
- if (targetChannelAlreadyAtTheTop || (considerPinnedChannels && isTargetChannelPinned)) {
- return channels;
- }
-
- const newChannels = [...channels];
-
- // target channel index is known, remove it from the list
- if (targetChannelExistsWithinList) {
- newChannels.splice(targetChannelIndex, 1);
- }
-
- // as position of pinned channels has to stay unchanged, we need to
- // find last pinned channel in the list to move the target channel after
- let lastPinnedChannelIndex: number | null = null;
- if (considerPinnedChannels) {
- lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels });
- }
-
- // re-insert it at the new place (to specific index if pinned channels are considered)
- newChannels.splice(
- typeof lastPinnedChannelIndex === 'number' ? lastPinnedChannelIndex + 1 : 0,
- 0,
- channelToMove,
- );
-
- return newChannels;
-};
-
-/**
- * Returns `true` only if object with `pinned_at` property is first within the `sort` array
- * or if `pinned_at` key of the `sort` object gets picked first when using `for...in` looping mechanism
- * and value of the `pinned_at` is either `1` or `-1`.
- */
-export const shouldConsiderPinnedChannels = (sort: ChannelListProps['sort']) => {
- const value = extractSortValue({ atIndex: 0, sort, targetKey: 'pinned_at' });
-
- if (typeof value !== 'number') return false;
-
- return Math.abs(value) === 1;
-};
-
-export const extractSortValue = ({
- atIndex,
- sort,
- targetKey,
-}: {
- atIndex: number;
- targetKey: keyof ChannelSortBase;
- sort?: ChannelListProps['sort'];
-}) => {
- if (!sort) return null;
- let option: null | ChannelSortBase = null;
-
- if (Array.isArray(sort)) {
- option = sort[atIndex] ?? null;
- } else {
- let index = 0;
- for (const key in sort) {
- if (index !== atIndex) {
- index++;
- continue;
- }
-
- if (key !== targetKey) {
- return null;
- }
-
- option = sort;
-
- break;
- }
- }
-
- return option?.[targetKey] ?? null;
-};
-
-/**
- * Returns `true` only if `archived` property is of type `boolean` within `filters` object.
- */
-export const shouldConsiderArchivedChannels = (filters: ChannelListProps['filters']) => {
- if (!filters) return false;
-
- return typeof filters.archived === 'boolean';
-};
-
-/**
- * Returns `true` only if `pinned_at` property is of type `string` within `membership` object.
- */
-export const isChannelPinned = (channel: Channel) => {
- if (!channel) return false;
-
- const membership = channel.state.membership;
-
- return typeof membership.pinned_at === 'string';
-};
-
-/**
- * Returns `true` only if `archived_at` property is of type `string` within `membership` object.
- */
-export const isChannelArchived = (channel: Channel) => {
- if (!channel) return false;
-
- const membership = channel.state.membership;
-
- return typeof membership.archived_at === 'string';
-};
diff --git a/src/components/ChannelListItem/ChannelListItem.tsx b/src/components/ChannelListItem/ChannelListItem.tsx
index 9ed0d9fac1..f93020cdce 100644
--- a/src/components/ChannelListItem/ChannelListItem.tsx
+++ b/src/components/ChannelListItem/ChannelListItem.tsx
@@ -18,6 +18,7 @@ import {
useComponentContext,
} from '../../context';
import { useChannelMembershipState } from '../ChannelList';
+import { useSlotForKey } from '../ChatView';
export type ChannelListItemUIProps = ChannelListItemProps & {
/** Image of Channel to display */
@@ -61,8 +62,6 @@ export type ChannelListItemProps = {
key?: string;
/** Custom ChannelListItem click handler function */
onSelect?: (event: React.MouseEvent) => void;
- /** Setter for selected Channel */
- setActiveChannel?: ChatContextValue['setActiveChannel'];
/** Object containing watcher parameters */
watchers?: { limit?: number; offset?: number };
};
@@ -81,12 +80,11 @@ export const ChannelListItem = (props: ChannelListItemProps) => {
getLatestMessagePreview = defaultGetLatestMessagePreview,
} = props;
const { ChannelListItemUI = DefaultChannelListItemUI } = useComponentContext();
- const {
- channel: activeChannel,
- client,
- isMessageAIGenerated,
- setActiveChannel,
- } = useChatContext('ChannelPreview');
+ const { client, isMessageAIGenerated } = useChatContext('ChannelPreview');
+ // Active = THIS channel is currently bound in some channel slot. Keyed on the
+ // channel's own cid (never "the first channel slot"), so multiple open channels
+ // each highlight independently.
+ const channelOpenInSlot = useSlotForKey(channel.cid ?? undefined);
const { t, userLanguage } = useTranslationContext('ChannelPreview');
const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({
channel,
@@ -106,8 +104,7 @@ export const ChannelListItem = (props: ChannelListItemProps) => {
lastMessage,
});
- const isActive =
- typeof active === 'undefined' ? activeChannel?.cid === channel.cid : active;
+ const isActive = typeof active === 'undefined' ? !!channelOpenInSlot : active;
const { muted } = useIsChannelMuted(channel);
useEffect(() => {
@@ -208,7 +205,6 @@ export const ChannelListItem = (props: ChannelListItemProps) => {
messageDeliveryStatus={messageDeliveryStatus}
muted={muted}
pinned={!!membership.pinned_at}
- setActiveChannel={setActiveChannel}
unread={unread}
/>
diff --git a/src/components/ChannelListItem/ChannelListItemUI.tsx b/src/components/ChannelListItem/ChannelListItemUI.tsx
index f8d86ef88f..5e5da1ce7e 100644
--- a/src/components/ChannelListItem/ChannelListItemUI.tsx
+++ b/src/components/ChannelListItem/ChannelListItemUI.tsx
@@ -8,6 +8,7 @@ import { ChannelAvatar as DefaultChannelAvatar } from '../Avatar';
import { Badge } from '../Badge';
import { IconMute, IconPin } from '../Icons';
import { useComponentContext, useTranslationContext } from '../../context';
+import { useChatViewNavigation } from '../ChatView';
import type { ChannelListItemUIProps } from './ChannelListItem';
import { SummarizedMessagePreview } from '../SummarizedMessagePreview';
@@ -24,9 +25,7 @@ const UnMemoizedChannelListItemUI = (props: ChannelListItemUIProps) => {
muted,
onSelect: customOnSelectChannel,
pinned,
- setActiveChannel,
unread,
- watchers,
} = props;
const {
@@ -34,6 +33,7 @@ const UnMemoizedChannelListItemUI = (props: ChannelListItemUIProps) => {
ChannelListItemActionButtons = DefaultChannelListItemActionButtons,
} = useComponentContext();
const { t } = useTranslationContext();
+ const { open } = useChatViewNavigation();
const channelPreviewButton = useRef(null);
@@ -43,8 +43,9 @@ const UnMemoizedChannelListItemUI = (props: ChannelListItemUIProps) => {
const onSelectChannel = (e: React.MouseEvent) => {
if (customOnSelectChannel) {
customOnSelectChannel(e);
- } else if (setActiveChannel) {
- setActiveChannel(channel, watchers);
+ } else {
+ // Selection is one navigation model: open the channel into a layout slot.
+ open({ key: channel.cid ?? undefined, kind: 'channel', source: channel });
}
if (channelPreviewButton?.current) {
channelPreviewButton.current.blur();
diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx
index ce8159a8cc..3ee03a8714 100644
--- a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx
+++ b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx
@@ -22,6 +22,13 @@ import {
TranslationProvider,
} from '../../../context';
+// Selection is one navigation model: clicking the item opens the channel into a
+// layout slot via ChatView navigation (no more setActiveChannel).
+const mockOpen = vi.fn();
+vi.mock('../../ChatView', () => ({
+ useChatViewNavigation: () => ({ open: mockOpen }),
+}));
+
const PREVIEW_TEST_ID = 'channel-list-item-button';
// Stub out ChannelListItemActionButtons to avoid needing ChannelListItemContext and DialogManager
@@ -74,7 +81,6 @@ describe('ChannelPreviewMessenger', () => {
displayImage='https://randomimage.com/src.jpg'
displayTitle='Channel name'
latestMessagePreview='Latest message!'
- setActiveChannel={vi.fn()}
unread={10}
{...props}
/>
@@ -104,14 +110,9 @@ describe('ChannelPreviewMessenger', () => {
expect(container).toMatchSnapshot();
});
- it('should call setActiveChannel on click', async () => {
- const setActiveChannel = vi.fn();
- const { container, getByTestId } = render(
- renderComponent({
- setActiveChannel,
- watchers: {},
- }),
- );
+ it('should open the channel into a slot on click', async () => {
+ mockOpen.mockClear();
+ const { container, getByTestId } = render(renderComponent({ watchers: {} }));
await waitFor(() => {
expect(getByTestId(PREVIEW_TEST_ID)).toBeInTheDocument();
@@ -120,8 +121,12 @@ describe('ChannelPreviewMessenger', () => {
fireEvent.click(getByTestId(PREVIEW_TEST_ID));
await waitFor(() => {
- expect(setActiveChannel).toHaveBeenCalledTimes(1);
- expect(setActiveChannel).toHaveBeenCalledWith(channel, {});
+ expect(mockOpen).toHaveBeenCalledTimes(1);
+ expect(mockOpen).toHaveBeenCalledWith({
+ key: channel.cid ?? undefined,
+ kind: 'channel',
+ source: channel,
+ });
});
const results = await axe(container.firstChild!.firstChild as Element);
diff --git a/src/components/ChannelListItem/hooks/useMessageDeliveryStatus.ts b/src/components/ChannelListItem/hooks/useMessageDeliveryStatus.ts
index 92f98a4269..af4fba19c5 100644
--- a/src/components/ChannelListItem/hooks/useMessageDeliveryStatus.ts
+++ b/src/components/ChannelListItem/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/ChannelListItem/styling/ChannelListItem.scss b/src/components/ChannelListItem/styling/ChannelListItem.scss
index 8dccfe55eb..4de81f0212 100644
--- a/src/components/ChannelListItem/styling/ChannelListItem.scss
+++ b/src/components/ChannelListItem/styling/ChannelListItem.scss
@@ -56,7 +56,7 @@
&:not(:disabled):active {
background: var(--str-chat__background-utility-pressed);
}
- &:not(:disabled)[aria-pressed='true'] {
+ &:not(:disabled)[aria-selected='true'] {
background: var(--str-chat__background-utility-selected);
}
diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx
index e73e22f025..e72dfadd3a 100644
--- a/src/components/Chat/Chat.tsx
+++ b/src/components/Chat/Chat.tsx
@@ -2,6 +2,8 @@ import type { PropsWithChildren } from 'react';
import React, { useMemo } from 'react';
import type { StreamChat } from 'stream-chat';
import {
+ ChannelPaginator,
+ ChannelPaginatorsOrchestrator,
ChannelSearchSource,
MessageSearchSource,
SearchController,
@@ -18,7 +20,6 @@ import {
import { useChat } from './hooks/useChat';
import { useReportLostConnectionSystemNotification } from './hooks/useReportLostConnectionSystemNotification';
import { useCreateChatContext } from './hooks/useCreateChatContext';
-import { useChannelsQueryState } from './hooks/useChannelsQueryState';
import type { CustomClasses } from '../../context/ChatContext';
import { ChatProvider } from '../../context/ChatContext';
import { useComponentContext } from '../../context/ComponentContext';
@@ -77,6 +78,12 @@ const ModalNotificationConfiguration = ({
export type ChatProps = {
/** The StreamChat client object */
client: StreamChat;
+ /**
+ * Orchestrator managing the channel-list paginators (data source + cross-list
+ * ownership). Defaults to a single `channels:default` paginator over the current
+ * user's channels.
+ */
+ channelPaginatorsOrchestrator?: ChannelPaginatorsOrchestrator;
/** Object containing custom CSS classnames to override the library's default container CSS */
customClasses?: CustomClasses;
/** Sets the default fallback language for UI component translation, defaults to 'en' for English */
@@ -105,6 +112,7 @@ export type ChatProps = {
*/
export const Chat = (props: PropsWithChildren) => {
const {
+ channelPaginatorsOrchestrator: customChannelPaginatorsOrchestrator,
children,
client,
customClasses,
@@ -117,21 +125,12 @@ export const Chat = (props: PropsWithChildren) => {
useImageFlagEmojisOnWindows = false,
} = props;
- const {
- channel,
- getAppSettings,
- latestMessageDatesByChannels,
- mutes,
- setActiveChannel,
- translators,
- } = useChat({
+ const { getAppSettings, latestMessageDatesByChannels, mutes, translators } = useChat({
client,
defaultLanguage,
i18nInstance,
});
- const channelsQueryState = useChannelsQueryState();
-
const searchController = useMemo(
() =>
customChannelSearchController ??
@@ -145,9 +144,25 @@ export const Chat = (props: PropsWithChildren) => {
[client, customChannelSearchController],
);
+ const channelPaginatorsOrchestrator = useMemo(
+ () =>
+ customChannelPaginatorsOrchestrator ??
+ new ChannelPaginatorsOrchestrator({
+ client,
+ paginators: [
+ new ChannelPaginator({
+ client,
+ filters: client.user?.id ? { members: { $in: [client.user.id] } } : {},
+ id: 'channels:default',
+ sort: { last_message_at: -1, pinned_at: 1, updated_at: -1 },
+ }),
+ ],
+ }),
+ [client, customChannelPaginatorsOrchestrator],
+ );
+
const chatContextValue = useCreateChatContext({
- channel,
- channelsQueryState,
+ channelPaginatorsOrchestrator,
client,
customClasses,
getAppSettings,
@@ -155,7 +170,6 @@ export const Chat = (props: PropsWithChildren) => {
latestMessageDatesByChannels,
mutes,
searchController,
- setActiveChannel,
theme,
useImageFlagEmojisOnWindows,
});
diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx
index 8f8aba14d7..dbeb996478 100644
--- a/src/components/Chat/__tests__/Chat.test.tsx
+++ b/src/components/Chat/__tests__/Chat.test.tsx
@@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
-import type { Channel, OwnUserResponse } from 'stream-chat';
+import type { OwnUserResponse } from 'stream-chat';
import { Chat } from '..';
@@ -141,10 +141,8 @@ describe('Chat', () => {
await waitFor(() => {
expect(context).toBeInstanceOf(Object);
expect(context.client).toBe(chatClient);
- expect(context.channel).toBeUndefined();
expect(context.mutes).toStrictEqual([]);
expect(context.theme).toBe('messaging light');
- expect(context.setActiveChannel).toBeInstanceOf(Function);
expect(context.client.getUserAgent()).toMatch(
new RegExp(
`^stream-chat-react-.+-${originalUserAgent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
@@ -245,50 +243,6 @@ describe('Chat', () => {
});
});
- describe('active channel', () => {
- it('setActiveChannel query if there is a watcher', async () => {
- let context: ChatContextValue;
- render(
-
- {
- context = ctx;
- }}
- />
- ,
- );
-
- const channel = fromPartial({ cid: 'cid', query: vi.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: ChatContextValue;
- render(
-
- {
- context = ctx;
- }}
- />
- ,
- );
-
- await waitFor(() => expect(context.setActiveChannel).not.toBeUndefined());
-
- const e = fromPartial({ preventDefault: vi.fn() });
- await act(() => context.setActiveChannel(undefined, {}, e));
- await waitFor(() => expect(e.preventDefault).toHaveBeenCalledTimes(1));
- });
- });
-
describe('connection notifications', () => {
it('publishes and removes system connection-lost notification on connection changes', async () => {
const client = getTestClient();
diff --git a/src/components/Chat/hooks/useChannelsQueryState.ts b/src/components/Chat/hooks/useChannelsQueryState.ts
deleted file mode 100644
index 3af1c3bef0..0000000000
--- a/src/components/Chat/hooks/useChannelsQueryState.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { Dispatch, SetStateAction } from 'react';
-import { useState } from 'react';
-import type { APIErrorResponse, ErrorFromResponse } from 'stream-chat';
-
-type ChannelQueryState =
- | 'uninitialized' // the initial state before the first channels query is triggered
- | 'reload' // the initial channels query (loading the first page) is in progress
- | 'load-more' // loading the next page of channels
- | null; // at least one channels page has been loaded and there is no query in progress at the moment
-
-export interface ChannelsQueryState {
- error: ErrorFromResponse | null;
- queryInProgress: ChannelQueryState;
- setError: Dispatch | null>>;
- setQueryInProgress: Dispatch>;
-}
-
-export const useChannelsQueryState = (): ChannelsQueryState => {
- const [error, setError] = useState | null>(null);
- const [queryInProgress, setQueryInProgress] =
- useState('uninitialized');
-
- return {
- error,
- queryInProgress,
- setError,
- setQueryInProgress,
- };
-};
diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts
index 0af9603cfd..50b9a3174f 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';
@@ -11,7 +11,6 @@ import {
import type {
AppSettingsAPIResponse,
- Channel,
Event,
Mute,
OwnUserResponse,
@@ -35,7 +34,6 @@ export const useChat = ({
userLanguage: 'en',
});
- const [channel, setChannel] = useState();
const [mutes, setMutes] = useState>([]);
const [latestMessageDatesByChannels, setLatestMessageDatesByChannels] = useState({});
@@ -113,33 +111,14 @@ 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);
- },
- [],
- );
-
useEffect(() => {
setLatestMessageDatesByChannels({});
}, [client.user?.id]);
return {
- channel,
getAppSettings,
latestMessageDatesByChannels,
mutes,
- setActiveChannel,
translators,
};
};
diff --git a/src/components/Chat/hooks/useCreateChatContext.ts b/src/components/Chat/hooks/useCreateChatContext.ts
index 6337a415de..3e9b7bf7cf 100644
--- a/src/components/Chat/hooks/useCreateChatContext.ts
+++ b/src/components/Chat/hooks/useCreateChatContext.ts
@@ -4,8 +4,7 @@ import type { ChatContextValue } from '../../../context/ChatContext';
export const useCreateChatContext = (value: ChatContextValue) => {
const {
- channel,
- channelsQueryState,
+ channelPaginatorsOrchestrator,
client,
customClasses,
getAppSettings,
@@ -13,14 +12,10 @@ export const useCreateChatContext = (value: ChatContextValue) => {
latestMessageDatesByChannels,
mutes,
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}
@@ -29,8 +24,7 @@ export const useCreateChatContext = (value: ChatContextValue) => {
const chatContext: ChatContextValue = useMemo(
() => ({
- channel,
- channelsQueryState,
+ channelPaginatorsOrchestrator,
client,
customClasses,
getAppSettings,
@@ -38,15 +32,12 @@ export const useCreateChatContext = (value: ChatContextValue) => {
latestMessageDatesByChannels,
mutes,
searchController,
- setActiveChannel,
theme,
useImageFlagEmojisOnWindows,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
- channelCid,
- channelsQueryError,
- channelsQueryInProgress,
+ channelPaginatorsOrchestrator,
clientValues,
getAppSettings,
searchController,
diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx
index ea2d51d5fa..1c433ade4f 100644
--- a/src/components/ChatView/ChatView.tsx
+++ b/src/components/ChatView/ChatView.tsx
@@ -2,6 +2,8 @@ import clsx from 'clsx';
import React, {
type ComponentType,
createContext,
+ type ReactNode,
+ useCallback,
useContext,
useEffect,
useMemo,
@@ -10,29 +12,68 @@ import React, {
import { useStableId } from '../UtilityComponents/useStableId';
import { Button, type ButtonProps } from '../Button';
-import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator';
import {
IconMessageBubble,
IconMessageBubbleFill,
IconThread,
IconThreadFill,
} from '../Icons';
-import { ThreadProvider } from '../Threads';
import { UnreadCountBadge } from '../Threads/UnreadCountBadge';
import {
+ DialogManagerProvider,
useChatContext,
- useComponentContext,
useTranslationContext,
} from '../../context';
import { useStateStore } from '../../store';
+// MERGE-RECONCILE: this file keeps PR #2909's slot/layout navigation architecture
+// (LayoutController / WorkspaceLayout / ChatViewNavigationProvider, context shape
+// activeView/setActiveView/layoutController) as the base, and grafts master's WAI-ARIA
+// tabs accessibility (ChatViewA11yContext, role=tablist/tab/tabpanel wiring) and Phosphor
+// icons (../Icons) on top. PR's `Icon` from '../Threads/icons' was removed by master and is
+// no longer imported. Reconcile if the layout navigation is later dropped in favor of the
+// simpler master ChatView.
import {
type ChatViewA11yContextValue,
createChatViewA11yContextValue,
DEFAULT_CHAT_VIEW_A11Y_CONTEXT_VALUE,
} from './ChatView.a11y.utility';
+import { ChatViewNavigationProvider } from './ChatViewNavigationContext';
+import { WorkspaceLayout } from './layout/WorkspaceLayout';
+import {
+ createLayoutRuntimeState,
+ LayoutController as LayoutControllerClass,
+} from './layoutController/LayoutController';
+import { createChatViewSlotBinding, getChatViewEntityBinding } from './slotBinding';
+import {
+ renderSlotFromRegistry,
+ resolveSlotKindRegistry,
+ SlotRegistryContext,
+} from './slotRegistry';
import type { PropsWithChildren } from 'react';
import type { Thread, ThreadManagerState } from 'stream-chat';
+import type {
+ ChatViewLayoutState,
+ DuplicateEntityPolicy,
+ LayoutController,
+ LayoutRuntimeState,
+ LayoutSlotBinding,
+ ResolveDuplicateEntity,
+ SlotName,
+} from './layoutController/layoutControllerTypes';
+import type {
+ ChatViewEntityBinding,
+ ChatViewSlotFallbackProps,
+ ChatViewSlotRenderers,
+ LayoutDescriptor,
+} from './slotBinding';
+import { getLayoutViewState } from './hooks';
+
+// Re-export the binding primitives + renderer types from their leaf modules so
+// existing `stream-chat-react` imports keep resolving through ChatView.
+export * from './slotBinding';
+export type { SlotKindDefinition, SlotKindRegistry } from './slotRegistry';
+export { defaultSlotKindRegistry } from './slotRegistry';
export type ChatView = 'channels' | 'threads';
@@ -45,12 +86,147 @@ export type ChatView = 'channels' | 'threads';
* 5) Tabs are always tabbable (tabIndex=0), so users can reach both without
* arrow-key navigation.
*/
+
+// Entity-binding primitives + per-kind renderer types live in the leaf module
+// `./slotBinding` (so the generic and the renderer registry can consume
+// them without an import cycle). Re-exported here for back-compat.
+export type ChatViewEntityInferer = {
+ kind: ChatViewEntityBinding['kind'];
+ match: (source: unknown) => boolean;
+ toBinding: (source: unknown) => ChatViewEntityBinding;
+};
+
+export type ChatViewBuiltinLayout = 'nav-rail-entity-list-workspace';
+
+export type ChatViewProps = PropsWithChildren<{
+ /**
+ * Optional id for the dialog manager ChatView hosts inside its `.str-chat` root. Dialogs
+ * opened by view content (context menus, member actions, …) resolve to this manager and
+ * their overlays render under `.str-chat`, so the SDK's `.str-chat`-scoped dialog CSS
+ * applies. Omit for a local (unregistered) manager.
+ */
+ dialogManagerId?: string;
+ duplicateEntityPolicy?: DuplicateEntityPolicy;
+ entityInferrers?: ChatViewEntityInferer[];
+ layout?: ChatViewBuiltinLayout;
+ layoutController?: LayoutController;
+ /** Declarative layout descriptors (D7). Defaults to the built-in channels/threads. */
+ layouts?: LayoutDescriptor[];
+ maxSlots?: number;
+ minSlots?: number;
+ resolveDuplicateEntity?: ResolveDuplicateEntity;
+ SlotFallback?: ComponentType;
+ slotNames?: string[];
+ slotFallbackComponents?: Partial<
+ Record>
+ >;
+ slotRenderers?: ChatViewSlotRenderers;
+ /**
+ * Per-view content (D8). The active view's node is rendered inside an a11y tabpanel;
+ * that view's slot topology comes from the matching `layouts` descriptor, and
+ * `viewActionSlotResolvers[view]` (if any) is registered while it is active. Replaces
+ * the removed `ChatView.Channels`/`ChatView.Threads` gated components — the app writes
+ * one mapping instead of two hand-composed, activeView-gated trees.
+ */
+ views?: Partial>;
+ /** Optional per-view navigation action slot resolvers. */
+ viewActionSlotResolvers?: Partial>;
+}>;
+
+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,
+ };
};
-export const ChatViewContext = createContext(undefined);
+const defaultLayoutController = new LayoutControllerClass({
+ initialState: {
+ activeView: 'channels',
+ layouts: {
+ channels: createLayoutRuntimeState({
+ availableSlots: createGeneratedSlotNames(DEFAULT_MAX_SLOTS),
+ }),
+ },
+ },
+});
+
+export const ChatViewContext = createContext({
+ activeChatView: 'channels',
+ activeView: 'channels',
+ entityInferers: [],
+ layoutController: defaultLayoutController,
+ registerViewActionSlotResolvers: () => undefined,
+ resolveActionTargetSlot: () => undefined,
+ setActiveView: () => undefined,
+});
const ChatViewA11yContext = createContext(
DEFAULT_CHAT_VIEW_A11Y_CONTEXT_VALUE,
);
@@ -69,81 +245,306 @@ export const useChatViewContext = () => {
};
const useChatViewA11yContext = () => useContext(ChatViewA11yContext);
-export const ChatView = ({ children }: PropsWithChildren) => {
- const [activeChatView, setActiveChatView] = useState('channels');
- const chatViewId = useStableId();
+const activeViewSelector = ({ activeView }: ChatViewLayoutState) => ({ activeView });
+const workspaceLayoutStateSelector = (state: ChatViewLayoutState) => ({
+ activeView: state.activeView,
+ viewState: getLayoutViewState(state),
+});
- const { theme } = useChatContext();
+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 BUILTIN_WORKSPACE_LAYOUT: ChatViewBuiltinLayout = 'nav-rail-entity-list-workspace';
+const DEFAULT_LIST_BINDING_KEY = 'list';
+
+// D7 — the built-in views expressed as declarative layout descriptors. Each layout
+// seeds its own list kind into its first slot (channels -> channelList,
+// threads -> threadList); the kind picks the renderer, so there is no `source.view`.
+const LIST_KIND_BY_LAYOUT: Record = {
+ channels: 'channelList',
+ threads: 'threadList',
+};
+const buildDefaultLayoutDescriptors = (slots: SlotName[]): LayoutDescriptor[] => {
+ const seedSlot = slots[0];
+ return (['channels', 'threads'] as const).map((id) => ({
+ id,
+ initialBindings: seedSlot
+ ? {
+ [seedSlot]: {
+ key: DEFAULT_LIST_BINDING_KEY,
+ kind: LIST_KIND_BY_LAYOUT[id],
+ source: {},
+ },
+ }
+ : undefined,
+ slots,
+ }));
+};
+// D7 — turn the declarative descriptors into seeded per-layout runtime state at
+// controller construction (replaces the imperative, lazy seed effect).
+const seedLayoutsFromDescriptors = (
+ descriptors: LayoutDescriptor[],
+ minSlots: number,
+): Partial> =>
+ descriptors.reduce>>((acc, descriptor) => {
+ const slotBindings: Record = {};
+ Object.entries(descriptor.initialBindings ?? {}).forEach(([slot, entity]) => {
+ if (entity) slotBindings[slot] = createChatViewSlotBinding(entity);
+ });
+ const availableCount = Math.min(Math.max(1, minSlots), descriptor.slots.length);
+ acc[descriptor.id] = createLayoutRuntimeState({
+ availableSlots: descriptor.slots.slice(0, availableCount),
+ slotBindings,
+ slotNames: descriptor.slots,
+ });
+ return acc;
+ }, {});
+
+export const ChatView = ({
+ children,
+ dialogManagerId,
+ duplicateEntityPolicy,
+ entityInferrers = [],
+ layout,
+ layoutController,
+ layouts: layoutsProp,
+ maxSlots,
+ minSlots,
+ resolveDuplicateEntity,
+ SlotFallback,
+ slotFallbackComponents,
+ slotNames,
+ slotRenderers,
+ viewActionSlotResolvers: viewActionSlotResolversProp,
+ views,
+}: ChatViewProps) => {
+ const { theme } = useChatContext();
+ const chatViewId = useStableId();
const a11yValue = useMemo(
() => createChatViewA11yContextValue(chatViewId),
[chatViewId],
);
+ const [viewActionSlotResolvers, setViewActionSlotResolvers] = useState<
+ Partial>
+ >({});
+ const { initialAvailableSlots, resolvedMaxSlots, resolvedMinSlots, resolvedSlotNames } =
+ useMemo(
+ () =>
+ resolveSlotTopology({
+ maxSlots,
+ minSlots,
+ slotNames,
+ }),
+ [maxSlots, minSlots, slotNames],
+ );
- const value = useMemo(() => ({ activeChatView, setActiveChatView }), [activeChatView]);
+ const layoutDescriptors = useMemo(
+ () => layoutsProp ?? buildDefaultLayoutDescriptors(resolvedSlotNames),
+ [layoutsProp, resolvedSlotNames],
+ );
- return (
-
-
- {children}
-
-
+ // In the built-in workspace and in `views`-map mode the SDK owns per-view rendering,
+ // so seed every layout's slot topology from its descriptor up front (a stable boolean
+ // keeps the controller identity constant across renders).
+ const seedAllLayouts = layout === BUILTIN_WORKSPACE_LAYOUT || !!views;
+
+ const internalLayoutController = useMemo(
+ () =>
+ new LayoutControllerClass({
+ duplicateEntityPolicy,
+ initialState: {
+ activeView: 'channels',
+ // D7 — when the SDK owns per-view rendering (workspace or `views` map), seed
+ // every layout's slots/bindings up front from its descriptor (no lazy seed
+ // effect). In bare `children` mode the app claims slots itself, so seed only
+ // the channels slot topology.
+ layouts: seedAllLayouts
+ ? seedLayoutsFromDescriptors(layoutDescriptors, resolvedMinSlots)
+ : {
+ channels: createLayoutRuntimeState({
+ availableSlots: initialAvailableSlots,
+ slotNames: resolvedSlotNames,
+ }),
+ },
+ maxSlots: resolvedMaxSlots,
+ minSlots: resolvedMinSlots,
+ },
+ resolveDuplicateEntity,
+ }),
+ [
+ duplicateEntityPolicy,
+ initialAvailableSlots,
+ layoutDescriptors,
+ resolvedMaxSlots,
+ resolvedMinSlots,
+ resolvedSlotNames,
+ resolveDuplicateEntity,
+ seedAllLayouts,
+ ],
);
-};
-const ChannelsView = ({ children }: PropsWithChildren) => {
- const { activeChatView } = useChatViewContext();
- const { chatViewPanelIds, chatViewTabIds } = useChatViewA11yContext();
- const isActive = activeChatView === 'channels';
+ const effectiveLayoutController = layoutController ?? internalLayoutController;
+
+ const { activeView } =
+ useStateStore(effectiveLayoutController.state, activeViewSelector) ??
+ activeViewSelector(effectiveLayoutController.state.getLatestValue());
+
+ const setActiveView = useCallback(
+ (cv: ChatView) => {
+ // Per-view layouts are retained across switches (that is the point of the
+ // `layouts` map): each view keeps its own slot bindings so returning to it
+ // restores what was open. We must NOT release the source view's channel/thread
+ // bindings here — display is slot-based, so releasing on switch would drop the
+ // open channel/thread (e.g. `?channel=` cleared when moving to the threads view).
+ effectiveLayoutController.setActiveView(cv);
+ },
+ [effectiveLayoutController],
+ );
- if (!isActive) return null;
+ 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;
+ });
+ },
+ [],
+ );
- return (
-
- {children}
-
+ const resolveActionTargetSlot = useCallback(
+ (view: ChatView, args: ResolveViewActionTargetSlotArgs) =>
+ viewActionSlotResolvers[view]?.[args.action]?.(args),
+ [viewActionSlotResolvers],
);
-};
-export type ThreadsViewContextValue = {
- activeThread: Thread | undefined;
- setActiveThread: (cv: ThreadsViewContextValue['activeThread']) => void;
-};
+ const value = useMemo(
+ () => ({
+ activeChatView: activeView,
+ activeView,
+ entityInferers: entityInferrers,
+ layoutController: effectiveLayoutController,
+ registerViewActionSlotResolvers,
+ resolveActionTargetSlot,
+ setActiveView,
+ }),
+ [
+ activeView,
+ effectiveLayoutController,
+ entityInferrers,
+ registerViewActionSlotResolvers,
+ resolveActionTargetSlot,
+ setActiveView,
+ ],
+ );
-const ThreadsViewContext = createContext({
- activeThread: undefined,
- setActiveThread: () => undefined,
-});
+ // Register per-view navigation action resolvers supplied via the `views`-mode prop.
+ useEffect(() => {
+ if (!viewActionSlotResolversProp) return;
+ const entries = Object.entries(viewActionSlotResolversProp) as Array<
+ [ChatView, ViewActionSlotResolvers | undefined]
+ >;
+ entries.forEach(([view, resolvers]) =>
+ registerViewActionSlotResolvers(view, resolvers),
+ );
+ return () => {
+ entries.forEach(([view]) => registerViewActionSlotResolvers(view, undefined));
+ };
+ }, [registerViewActionSlotResolvers, viewActionSlotResolversProp]);
-export const useThreadsViewContext = () => useContext(ThreadsViewContext);
+ const workspaceLayoutState =
+ useStateStore(effectiveLayoutController.state, workspaceLayoutStateSelector) ??
+ workspaceLayoutStateSelector(effectiveLayoutController.state.getLatestValue());
+ const { viewState } = workspaceLayoutState;
-const ThreadsView = ({ children }: PropsWithChildren) => {
- const { activeChatView } = useChatViewContext();
- const { chatViewPanelIds, chatViewTabIds } = useChatViewA11yContext();
- const [activeThread, setActiveThread] =
- useState(undefined);
+ const slotKindRegistry = useMemo(
+ () => resolveSlotKindRegistry(slotRenderers),
+ [slotRenderers],
+ );
- const value = useMemo(() => ({ activeThread, setActiveThread }), [activeThread]);
- const isActive = activeChatView === 'threads';
+ // D8 — the active view's content is rendered in a single a11y tabpanel; per-view slot
+ // topology is seeded from its `layouts` descriptor. Always-on `children` (e.g. sync
+ // helpers, dialog managers) render alongside it, regardless of the active view.
+ const activeViewContent = views?.[activeView];
- if (!isActive) return null;
+ const content = views ? (
+ <>
+ {children}
+ {activeViewContent != null && (
+
+ {activeViewContent}
+
+ )}
+ >
+ ) : layout === BUILTIN_WORKSPACE_LAYOUT ? (
+ (() => {
+ const slots = viewState.availableSlots.map((slot) => {
+ const content = renderSlotFromRegistry(
+ getChatViewEntityBinding(viewState.slotBindings[slot]),
+ slot,
+ slotKindRegistry,
+ );
+ const Fallback = resolveSlotFallbackComponent({
+ slot,
+ SlotFallback,
+ slotFallbackComponents,
+ });
+
+ return {
+ content: content ?? ,
+ slot,
+ };
+ });
+
+ return } slots={slots} />;
+ })()
+ ) : (
+ children
+ );
return (
-
-
- {children}
-
-
+
+
+
+
+
+ {/* Host the chat-view dialog manager INSIDE `.str-chat` so dialogs opened by
+ view content (context menus, member actions, …) portal here and inherit the
+ `.str-chat`-scoped dialog CSS. Nested managers (e.g. MessageList's) still win
+ where present. */}
+
+ {content}
+
+
+
+
+
+
);
};
@@ -173,53 +574,10 @@ export const useActiveThread = ({ activeThread }: { activeThread?: Thread }) =>
}, [activeThread]);
};
-// ThreadList under View.Threads context, will access setting function and on item click will set activeThread
-// which can be accessed for the ease of use by ThreadAdapter which forwards it to required ThreadProvider
-// ThreadList can easily live without this context and click handler can be overriden, ThreadAdapter is then no longer needed
-/**
- * // this setup still works
- * const MyCustomComponent = () => {
- * const [activeThread, setActiveThread] = useState();
- *
- * return <>
- * // simplified
- *
- *
- *
- *
- * >
- * }
- *
- */
-const ThreadAdapter = ({ children }: PropsWithChildren) => {
- const { client } = useChatContext('ThreadAdapter');
- const { EmptyStateIndicator = DefaultEmptyStateIndicator } =
- useComponentContext('ThreadAdapter');
- const { activeThread } = useThreadsViewContext();
- const { t } = useTranslationContext('ThreadAdapter');
- const { isLoading, ready } = useStateStore(
- client.threads.state,
- threadAdapterSelector,
- ) ?? {
- isLoading: false,
- ready: false,
- };
-
- useActiveThread({ activeThread });
-
- if (!activeThread && ready && !isLoading && EmptyStateIndicator) {
- return (
-
-
-
- );
- }
-
- return {children};
-};
+// D8 — `ThreadAdapter` is retired: the threads view renders the thread(s) bound in
+// thread slots (via `useSlotThreads` + `ThreadProvider`), so there is no single
+// `activeThread` adapter. `useActiveThread` remains for callers that render a thread
+// panel and want focus-driven activate/deactivate.
export const ChatViewSelectorButton = ({
ActiveIcon,
@@ -267,11 +625,6 @@ export const ChatViewSelectorButton = ({
);
};
-const threadAdapterSelector = ({ pagination, ready }: ThreadManagerState) => ({
- isLoading: pagination.isLoading,
- ready,
-});
-
const unreadThreadCountSelector = ({ unreadThreadCount }: ThreadManagerState) => ({
unreadThreadCount,
});
@@ -283,11 +636,11 @@ export type ChatViewSelectorItemProps = {
export const ChatViewChannelsSelectorButton = ({
iconOnly = true,
}: ChatViewSelectorItemProps) => {
- const { activeChatView, setActiveChatView } = useChatViewContext();
+ const { activeView, setActiveView } = useChatViewContext();
const { chatViewPanelIds, chatViewTabIds } = useChatViewA11yContext();
const { t } = useTranslationContext();
- const isActive = activeChatView === 'channels';
+ const isActive = activeView === 'channels';
return (
setActiveChatView('channels')}
- onPointerDown={() => setActiveChatView('channels')}
+ onClick={() => setActiveView('channels')}
+ onPointerDown={() => setActiveView('channels')}
tabIndex={0}
text={t('Channels')}
/>
@@ -317,11 +670,11 @@ export const ChatViewThreadsSelectorButton = ({
) ?? {
unreadThreadCount: 0,
};
- const { activeChatView, setActiveChatView } = useChatViewContext();
+ const { activeView, setActiveView } = useChatViewContext();
const { chatViewPanelIds, chatViewTabIds } = useChatViewA11yContext();
const { t } = useTranslationContext();
- const isActive = activeChatView === 'threads';
+ const isActive = activeView === 'threads';
return (
setActiveChatView('threads')}
- onPointerDown={() => setActiveChatView('threads')}
+ onClick={() => setActiveView('threads')}
+ onPointerDown={() => setActiveView('threads')}
tabIndex={0}
text={t('Threads')}
>
@@ -388,7 +741,4 @@ const ChatViewSelector = ({
);
};
-ChatView.Channels = ChannelsView;
-ChatView.Threads = ThreadsView;
-ChatView.ThreadAdapter = ThreadAdapter;
ChatView.Selector = ChatViewSelector;
diff --git a/src/components/ChatView/ChatViewNavigationContext.tsx b/src/components/ChatView/ChatViewNavigationContext.tsx
new file mode 100644
index 0000000000..636c608e39
--- /dev/null
+++ b/src/components/ChatView/ChatViewNavigationContext.tsx
@@ -0,0 +1,470 @@
+import React, { createContext, useContext, useMemo } from 'react';
+
+import { useStateStore } from '../../store';
+import {
+ createChatViewSlotBinding,
+ getChatViewEntityBinding,
+ useChatViewContext,
+} from './ChatView';
+import {
+ isPersistentSlotKind,
+ resolveSlotKindView,
+ useSlotRegistry,
+} from './slotRegistry';
+
+import type { PropsWithChildren } from 'react';
+import type {
+ LocalMessage,
+ Channel as StreamChannel,
+ StreamChat,
+ 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';
+
+type ViewSlotRuntime = {
+ activeViewState: ChatViewLayoutViewState;
+ availableSlots: SlotName[];
+ orderedSlots: SlotName[];
+ slotBindings: Record;
+ slotLayers: Record;
+};
+
+const resolveGeneratedSlots = (slotCount: number): SlotName[] =>
+ Array.from(
+ { length: Math.max(0, slotCount) },
+ (_, index) => `slot${index + 1}` as SlotName,
+ );
+
+// D6 — the resolver is kind-driven and persistent-aware (via the registry), not
+// keyed on a fixed set of actions or a hardcoded list-kind set. Precedence:
+// reuse a same-kind slot -> first free non-persistent -> first free -> first
+// non-persistent. Persistent (list) slots are never an implicit target and there
+// is deliberately no "last slot" fallback, so when only persistent slots remain
+// the resolver returns undefined and `openInLayout` rejects rather than
+// destroying a list.
+const resolveDefaultTargetSlot = ({
+ additive,
+ isPersistentSlot,
+ kind,
+ requestedSlot,
+ runtime,
+}: {
+ additive?: boolean;
+ isPersistentSlot: (slot: SlotName) => boolean;
+ kind: ChatViewEntityBinding['kind'];
+ requestedSlot?: SlotName;
+ runtime: ViewSlotRuntime;
+}): SlotName | undefined => {
+ if (requestedSlot && runtime.availableSlots.includes(requestedSlot)) {
+ return requestedSlot;
+ }
+
+ const readSlotKind = (slot: SlotName) =>
+ getChatViewEntityBinding(runtime.slotBindings[slot])?.kind;
+ // A slot is "free" only when it has neither a base binding NOR a covering layer stack — binding
+ // into the base beneath a layer would render invisibly, so a layered slot counts as occupied.
+ const isFree = (slot: SlotName) =>
+ !runtime.slotBindings[slot] && !runtime.slotLayers[slot]?.length;
+ const sameKind = runtime.availableSlots.find((slot) => readSlotKind(slot) === kind);
+ const firstFreeNonPersistent = runtime.availableSlots.find(
+ (slot) => isFree(slot) && !isPersistentSlot(slot),
+ );
+ const firstFree = runtime.availableSlots.find((slot) => isFree(slot));
+ // When every slot is occupied, evict the LAST non-persistent slot rather than the first.
+ // Slots run primary -> secondary, so the first slot holds the anchor content (e.g. the main
+ // channel); a newly-opened panel — a reply thread opened while a 2nd channel occupies the
+ // secondary slot, say — should replace the most-recent secondary panel, never make the
+ // primary disappear.
+ const lastNonPersistent = [...runtime.availableSlots]
+ .reverse()
+ .find((slot) => !isPersistentSlot(slot));
+
+ // `additive` opens *beside* existing content (ctrl/⌘-click): skip the same-kind slot, which
+ // would replace the current primary, and prefer a free/secondary slot instead. In a
+ // single-slot layout it falls through to that slot, so it degrades to a normal open.
+ if (additive) {
+ return firstFreeNonPersistent ?? firstFree ?? lastNonPersistent;
+ }
+
+ return sameKind ?? firstFreeNonPersistent ?? firstFree ?? lastNonPersistent;
+};
+
+export type OpenThreadTarget =
+ | StreamThread
+ | { channel: StreamChannel; message: LocalMessage };
+
+/**
+ * Binding builder for the `thread` kind (D6). The `{ channel, message }` ->
+ * `Thread` construction (with dedupe against `client.threads`) lives here so
+ * callers can drive the generic `open` with a ready binding instead of a
+ * thread-specific navigation method.
+ */
+export const createThreadEntityBinding = (
+ client: StreamChat,
+ target: { channel: StreamChannel; message: LocalMessage },
+): ChatViewEntityBinding => {
+ const existingThread = client.threads.threadsById[target.message.id];
+ const thread =
+ existingThread ??
+ new StreamThreadClass({
+ channel: target.channel,
+ client,
+ parentMessage: target.message,
+ });
+ return { key: thread.id ?? undefined, kind: 'thread', source: thread };
+};
+
+/** Options for {@link ChatViewNavigation.open}. */
+export type ChatViewOpenOptions = {
+ /**
+ * Open *beside* existing content instead of replacing it.
+ *
+ * By default `open` reuses the slot that already holds the binding's `kind` (so selecting a
+ * channel replaces the current channel). With `additive: true` the resolver skips that
+ * same-kind slot and instead targets the first free non-persistent slot — falling back to the
+ * last non-persistent slot when every slot is occupied. The effect is that the entity opens in
+ * a *secondary* slot next to the current one (e.g. ctrl/⌘-click a channel to open a 2nd channel
+ * side-by-side, or a search result beside the open channel).
+ *
+ * In a single-slot layout there is no secondary slot to open into, so it falls back to that
+ * one slot and behaves like a normal open. Ignored when an explicit `slot` is provided.
+ *
+ * @default false
+ */
+ additive?: boolean;
+ /**
+ * Open non-destructively: push the binding as a **layer** on top of the resolved target slot's
+ * current content instead of replacing it, and skip dependent-slot invalidation (nothing is
+ * being replaced). `close`/`popLayer` on that slot later restores what was underneath, at its
+ * exact state. Compose with `additive` to target the *secondary* slot — e.g.
+ * `open(channel, { additive: true, layer: true })` stacks a channel over an open reply thread
+ * without closing it. Orthogonal to slot resolution: `layer` chooses stack-vs-replace, while
+ * `slot`/`additive` choose *which* slot.
+ *
+ * @default false
+ */
+ layer?: boolean;
+ /**
+ * Bind into this specific slot, when it exists in the target view's layout. Takes precedence
+ * over `additive` and over the default kind-based slot resolution. Ignored (falls back to the
+ * default resolution) when the named slot is not part of the active layout.
+ */
+ slot?: SlotName;
+};
+
+export type ChatViewNavigation = {
+ /**
+ * Dismiss the top of `slot`. If the slot has layers (see {@link pushLayer}), pop the topmost
+ * layer, revealing what's beneath at its preserved state. Only when no layers remain does this
+ * release the base binding. No-op for empty or persistent slots.
+ */
+ close: (slot: SlotName) => void;
+ /** Hide `slot` without releasing its binding (subtree stays mounted). */
+ hide: (slot: SlotName) => void;
+ /**
+ * Push `binding` as a **layer** on top of `slot`'s current content. The content beneath stays
+ * mounted (state/scroll preserved) and hidden; the layer covers it. Use for transient overlays
+ * that should return to the underlying view — e.g. a member profile opened over a thread, where
+ * `close`/`popLayer` restores the thread exactly where it was. Ephemeral (not serialized).
+ */
+ pushLayer: (slot: SlotName, binding: ChatViewEntityBinding) => void;
+ /** Pop `slot`'s top layer, revealing the layer/base beneath. No-op when there are no layers. */
+ popLayer: (slot: SlotName) => void;
+ /**
+ * Resolve a target slot for `binding` and bind it there, switching to the binding kind's view
+ * first if needed (e.g. a `channel` binding activates the channels view).
+ *
+ * Slot resolution, in order:
+ * 1. `options.slot`, if that slot exists in the target view's layout;
+ * 2. otherwise, unless `options.additive` is set, the slot already holding this `kind`
+ * (replacing its content);
+ * 3. the first free non-persistent slot;
+ * 4. the first free slot;
+ * 5. the last non-persistent slot (evicting a secondary panel — never the primary).
+ *
+ * See {@link ChatViewOpenOptions.additive} for opening *beside* current content instead of
+ * replacing.
+ *
+ * Returns an {@link OpenResult}; if no slot can be resolved the call is a rejected no-op — no
+ * view switch and no dependent-slot teardown, so a rejected open never leaves a partial state.
+ */
+ open: (binding: ChatViewEntityBinding, options?: ChatViewOpenOptions) => OpenResult;
+ /** Switch the active view (channels/threads); optionally focus a specific `slot`. */
+ openView: (view: ChatView, options?: { slot?: SlotName }) => void;
+ /** Reveal a previously hidden `slot`. */
+ unhide: (slot: SlotName) => void;
+};
+
+const chatViewNavigationStateSelector = (state: ChatViewLayoutState) => ({
+ activeView: state.activeView,
+});
+
+const ChatViewNavigationContext = createContext({
+ close: () => undefined,
+ hide: () => undefined,
+ open: () => ({ reason: 'no-available-slot', status: 'rejected' }),
+ openView: () => undefined,
+ popLayer: () => undefined,
+ pushLayer: () => undefined,
+ unhide: () => undefined,
+});
+
+export const useChatViewNavigation = () => useContext(ChatViewNavigationContext);
+
+/** The slot (in the active view) currently holding a binding of `kind`, if any. */
+export const useSlotForKind = (
+ kind: ChatViewEntityBinding['kind'],
+): SlotName | undefined => {
+ const { availableSlots, slotBindings } = useLayoutViewState();
+ return useMemo(
+ () =>
+ availableSlots.find(
+ (slot) => getChatViewEntityBinding(slotBindings[slot])?.kind === kind,
+ ),
+ [availableSlots, kind, slotBindings],
+ );
+};
+
+/** The slot (in the active view) whose binding carries entity `key`, if any. */
+export const useSlotForKey = (key?: string): SlotName | undefined => {
+ const { availableSlots, slotBindings } = useLayoutViewState();
+ return useMemo(
+ () =>
+ key === undefined
+ ? undefined
+ : availableSlots.find(
+ (slot) => getChatViewEntityBinding(slotBindings[slot])?.key === key,
+ ),
+ [availableSlots, key, slotBindings],
+ );
+};
+
+export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => {
+ const { layoutController, resolveActionTargetSlot } = useChatViewContext();
+ const registry = useSlotRegistry();
+ const { availableSlots, slotBindings, slotLayers } = useLayoutViewState();
+ const { activeView } =
+ useStateStore(layoutController.state, chatViewNavigationStateSelector) ??
+ chatViewNavigationStateSelector(layoutController.state.getLatestValue());
+
+ const value = useMemo(() => {
+ const slotKind = (slot: SlotName) =>
+ getChatViewEntityBinding(slotBindings[slot])?.kind;
+
+ const findCandidateSlotsByKind = (kind: ChatViewEntityBinding['kind']) =>
+ availableSlots.filter((slot) => slotKind(slot) === kind);
+
+ 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,
+ orderedSlots: viewState.slotNames?.length
+ ? viewState.slotNames
+ : resolveGeneratedSlots(inferredMaxSlots),
+ slotBindings: viewState.slotBindings,
+ slotLayers: viewState.slotLayers ?? {},
+ };
+ };
+
+ const makeIsPersistentSlot = (runtime: ViewSlotRuntime) => (slot: SlotName) =>
+ isPersistentSlotKind(
+ registry,
+ getChatViewEntityBinding(runtime.slotBindings[slot])?.kind,
+ );
+
+ 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 resolveTargetSlot = ({
+ additive,
+ kind,
+ requestedSlot,
+ runtime,
+ view,
+ }: {
+ additive?: boolean;
+ kind: ChatViewEntityBinding['kind'];
+ requestedSlot?: SlotName;
+ runtime: ViewSlotRuntime;
+ view: ChatView;
+ }) => {
+ // Preserve the public per-view resolver extension point (keyed on the
+ // channel/thread actions) for the kinds that have one. `additive` (open beside) skips it
+ // and uses the default resolver, which is where the "prefer a free/secondary slot" logic
+ // lives.
+ const action =
+ !additive && kind === 'channel'
+ ? 'openChannel'
+ : !additive && kind === 'thread'
+ ? 'openThread'
+ : undefined;
+ if (action) {
+ const args: ResolveViewActionTargetSlotArgs = {
+ action,
+ activeView: view,
+ availableSlots: runtime.availableSlots,
+ requestedSlot,
+ slotBindings: runtime.slotBindings,
+ slotNames: runtime.orderedSlots,
+ };
+ const customTargetSlot = resolveActionTargetSlot(view, args);
+ if (customTargetSlot) {
+ const materialized = materializeTargetSlot(runtime, customTargetSlot);
+ if (materialized) return materialized;
+ }
+ }
+
+ return materializeTargetSlot(
+ runtime,
+ resolveDefaultTargetSlot({
+ additive,
+ isPersistentSlot: makeIsPersistentSlot(runtime),
+ kind,
+ requestedSlot,
+ runtime,
+ }),
+ );
+ };
+
+ const openView: ChatViewNavigation['openView'] = (view, options) => {
+ layoutController.openView(view, { slot: options?.slot });
+ };
+
+ const releaseKind = (kind: ChatViewEntityBinding['kind']) =>
+ findCandidateSlotsByKind(kind).forEach((slot) => layoutController.release(slot));
+
+ const open: ChatViewNavigation['open'] = (binding, options) => {
+ // Resolve the target slot BEFORE any mutation: a rejected open is a no-op
+ // (no view switch, no dependent-slot teardown), never a destructive
+ // partial navigation.
+ const kindView = resolveSlotKindView(registry, binding.kind);
+ const targetView = kindView ?? activeView;
+ const runtime = buildRuntimeForView(targetView);
+ const targetSlot = resolveTargetSlot({
+ additive: options?.additive,
+ kind: binding.kind,
+ requestedSlot: options?.slot,
+ runtime,
+ view: targetView,
+ });
+
+ if (!targetSlot) {
+ return { reason: 'no-available-slot', status: 'rejected' };
+ }
+
+ // Kinds that declare a target view switch to it on open (e.g. channel ->
+ // channels); view-agnostic kinds (thread) open in the active view.
+ if (kindView) openView(targetView, options);
+
+ // Base policy — "open in the secondary slot; if it's occupied, stack a layer" for both
+ // channels (⌘/ctrl-click) and threads (reply-in-thread). Push a LAYER when:
+ // - `options.layer` explicitly asks for it, OR
+ // - the target is a SECONDARY slot (not the primary/anchor slot) that is already occupied
+ // (a base binding or a covering layer) and this is NOT an in-place same-kind replace.
+ // The primary/anchor slot (first non-persistent slot) always base-binds — selecting a channel
+ // there replaces it, and single-slot layouts keep replacing rather than layering. A same-kind
+ // base (channel over channel, thread over thread in its own slot) is an intentional replace
+ // and stays a base bind. Everything else landing on an occupied secondary slot stacks on top
+ // instead of evicting/hiding it — `close`/`popLayer` restores what's beneath.
+ const isPersistentSlot = makeIsPersistentSlot(runtime);
+ const anchorSlot = runtime.availableSlots.find((slot) => !isPersistentSlot(slot));
+ const targetBase = runtime.slotBindings[targetSlot];
+ const targetBaseKind = getChatViewEntityBinding(targetBase)?.kind;
+ const targetOccupied = !!targetBase || !!runtime.slotLayers[targetSlot]?.length;
+ const sameKindBaseReplace = !!targetBase && targetBaseKind === binding.kind;
+ const isSecondaryTarget = targetSlot !== anchorSlot;
+
+ if (
+ options?.layer ||
+ (isSecondaryTarget && targetOccupied && !sameKindBaseReplace)
+ ) {
+ layoutController.pushLayer(targetSlot, createChatViewSlotBinding(binding));
+ return { slot: targetSlot, status: 'layered' };
+ }
+
+ // Dependent-slot invalidation: replacing a channel closes threads bound to
+ // the previous channel. (Interim kind check; a later step may express this
+ // as registry policy.) Only on a real base replace — layered opens above skip it.
+ if (binding.kind === 'channel') releaseKind('thread');
+
+ return layoutController.openInLayout(createChatViewSlotBinding(binding), {
+ targetSlot,
+ });
+ };
+
+ const close: ChatViewNavigation['close'] = (slot) => {
+ if (!availableSlots.includes(slot)) return;
+ // Layers dismiss top-down: pop the topmost layer first and only release the base binding
+ // once no layers remain.
+ if (slotLayers?.[slot]?.length) {
+ layoutController.popLayer(slot);
+ return;
+ }
+ // Persistent kinds (nav-rail lists) are hide-only; close never releases them.
+ if (isPersistentSlotKind(registry, slotKind(slot))) return;
+ layoutController.release(slot);
+ };
+
+ const hide: ChatViewNavigation['hide'] = (slot) => {
+ if (availableSlots.includes(slot)) layoutController.hide(slot);
+ };
+
+ const pushLayer: ChatViewNavigation['pushLayer'] = (slot, binding) => {
+ if (!availableSlots.includes(slot)) return;
+ layoutController.pushLayer(slot, createChatViewSlotBinding(binding));
+ };
+
+ const popLayer: ChatViewNavigation['popLayer'] = (slot) => {
+ if (availableSlots.includes(slot)) layoutController.popLayer(slot);
+ };
+
+ const unhide: ChatViewNavigation['unhide'] = (slot) => {
+ if (availableSlots.includes(slot)) layoutController.unhide(slot);
+ };
+
+ return { close, hide, open, openView, popLayer, pushLayer, unhide };
+ }, [
+ activeView,
+ availableSlots,
+ layoutController,
+ registry,
+ resolveActionTargetSlot,
+ slotBindings,
+ slotLayers,
+ ]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/ChatView/__tests__/ChatView.test.tsx b/src/components/ChatView/__tests__/ChatView.test.tsx
index 018f485b09..bf2155d88b 100644
--- a/src/components/ChatView/__tests__/ChatView.test.tsx
+++ b/src/components/ChatView/__tests__/ChatView.test.tsx
@@ -1,6 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import type React from 'react';
-import { useEffect } from 'react';
import { fromPartial } from '@total-typescript/shoehorn';
import { axe } from '../../../../axe-helper';
@@ -9,69 +7,7 @@ import {
getTestClientWithUser,
mockTranslationContextValue,
} from '../../../mock-builders';
-import { ChatView, useChatViewContext } from '../ChatView';
-
-vi.mock('../../Threads', async () => {
- const React = await import('react');
-
- return {
- ThreadProvider: ({ children }: { children: React.ReactNode }) => (
- {children}
- ),
- };
-});
-
-const ActivateThreadsView = () => {
- const { setActiveChatView } = useChatViewContext();
-
- useEffect(() => {
- setActiveChatView('threads');
- }, [setActiveChatView]);
-
- return null;
-};
-
-const renderComponent = async (threadManagerState: any) => {
- const client = await getTestClientWithUser();
- const currentThreadManagerState = client.threads.state.getLatestValue();
-
- client.threads.state.next({
- ...currentThreadManagerState,
- ...threadManagerState,
- pagination: {
- ...currentThreadManagerState.pagination,
- ...threadManagerState.pagination,
- },
- });
-
- return render(
-
-
-
-
-
-
-
-
-
-
-
-
- ,
- );
-};
+import { ChatView } from '../ChatView';
const renderSelector = async (selectorProps?: any) => {
const client = await getTestClientWithUser();
@@ -85,7 +21,6 @@ const renderSelector = async (selectorProps?: any) => {
latestMessageDatesByChannels: {},
mutes: [],
searchController: fromPartial({}),
- setActiveChannel: vi.fn(),
theme: 'messaging light',
useImageFlagEmojisOnWindows: false,
}}
@@ -111,74 +46,24 @@ const renderSelectorWithPanels = async (selectorProps?: any) => {
latestMessageDatesByChannels: {},
mutes: [],
searchController: fromPartial({}),
- setActiveChannel: vi.fn(),
theme: 'messaging light',
useImageFlagEmojisOnWindows: false,
}}
>
-
+ ,
+ threads: ,
+ }}
+ >
-
-
-
-
-
-
,
);
};
-describe('ChatView.ThreadAdapter', () => {
- afterEach(() => {
- cleanup();
- vi.clearAllMocks();
- });
-
- it('renders the empty message state when no thread is selected after loading completes', async () => {
- await renderComponent({
- pagination: { isLoading: false },
- ready: true,
- threads: [{ id: 'thread-1' }],
- });
-
- expect(
- await screen.findByText('Select a thread to continue the conversation'),
- ).toBeInTheDocument();
- expect(screen.queryByTestId('thread-provider')).not.toBeInTheDocument();
- });
-
- it('renders the empty message state when the thread list is empty after loading completes', async () => {
- await renderComponent({
- pagination: { isLoading: false },
- ready: true,
- threads: [],
- });
-
- expect(
- await screen.findByText('Select a thread to continue the conversation'),
- ).toBeInTheDocument();
- expect(screen.queryByTestId('thread-provider')).not.toBeInTheDocument();
- });
-
- it('does not render the empty message state while threads are still loading', async () => {
- await renderComponent({
- pagination: { isLoading: true },
- ready: false,
- threads: [],
- });
-
- await waitFor(() => {
- expect(
- screen.queryByText('Select a thread to continue the conversation'),
- ).not.toBeInTheDocument();
- });
- expect(screen.getByTestId('thread-provider')).toBeInTheDocument();
- });
-});
-
describe('ChatView.Selector', () => {
afterEach(() => {
cleanup();
diff --git a/src/components/ChatView/__tests__/ChatViewNavigation.test.tsx b/src/components/ChatView/__tests__/ChatViewNavigation.test.tsx
new file mode 100644
index 0000000000..b6d4d74ef2
--- /dev/null
+++ b/src/components/ChatView/__tests__/ChatViewNavigation.test.tsx
@@ -0,0 +1,822 @@
+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 { getLayoutViewState } from '../hooks';
+
+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;
+
+// D7 — active-view slot state lives under `layouts[activeView]`; project it (plus the
+// top-level `activeView`) into one object so assertions can read both.
+const viewState = (controller?: LayoutController | null) => {
+ if (!controller) return undefined;
+ const state = controller.state.getLatestValue();
+ return { ...getLayoutViewState(state), activeView: state.activeView };
+};
+
+const createChatContextValue = (): ChatContextValue =>
+ ({
+ client: {
+ threads: {
+ state: new StateStore({
+ unreadThreadCount: 0,
+ }),
+ },
+ },
+ getAppSettings: vi.fn(() => null),
+ latestMessageDatesByChannels: {},
+ openMobileNav: vi.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 = viewState(capturedController);
+ expect(openChannelState?.activeView).toBe('channels');
+ expect(getChatViewEntityBinding(openChannelState?.slotBindings.slot1)?.kind).toBe(
+ 'channel',
+ );
+
+ fireEvent.click(screen.getByText('open-thread'));
+ const openThreadState = viewState(capturedController);
+ 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 = viewState(capturedController);
+ 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(viewState(capturedController)?.slotBindings.slot1)?.kind,
+ ).toBe('thread');
+
+ fireEvent.click(screen.getByText('open-channel-b'));
+ const stateAfterSecondChannelOpen = viewState(capturedController);
+ 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(viewState(capturedController)).toMatchObject({
+ hiddenSlots: {
+ slot1: true,
+ },
+ });
+
+ fireEvent.click(screen.getByText('unhide-list'));
+ expect(viewState(capturedController)?.hiddenSlots.slot1).toBe(false);
+ });
+
+ 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(viewState(capturedController)).toMatchObject({
+ activeView: 'threads',
+ });
+ });
+
+ it('opens a thread into the next free slot alongside an open channel', () => {
+ 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 = viewState(capturedController);
+ expect(openThreadState?.availableSlots).toEqual(['slot1', 'slot2']);
+ expect(getChatViewEntityBinding(openThreadState?.slotBindings.slot1)?.kind).toBe(
+ 'channel',
+ );
+ expect(getChatViewEntityBinding(openThreadState?.slotBindings.slot2)?.kind).toBe(
+ 'thread',
+ );
+ });
+
+ // Regression: opening two channels side-by-side (⌘/ctrl-click a channel in the list opens it
+ // into the secondary slot beside the primary channel). This relies on the layout holding two
+ // `channel` bindings in two slots at once. The second channel must open into an EXPLICIT slot
+ // — the default same-kind resolution would otherwise replace the channel already open — and
+ // doing so must leave the first channel's slot untouched.
+ it('keeps two channels bound side-by-side when the second opens into an explicit slot', () => {
+ const primaryChannel = makeChannel('messaging:primary');
+ const secondaryChannel = makeChannel('messaging:secondary');
+ let capturedController: LayoutController | undefined;
+
+ const Harness = () => {
+ const navigation = useChatViewNavigation();
+ const { layoutController } = useChatViewContext();
+ capturedController = layoutController;
+
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText('open-primary'));
+ fireEvent.click(screen.getByText('open-secondary'));
+
+ const state = viewState(capturedController);
+ const slot1Binding = getChatViewEntityBinding(state?.slotBindings.slot1);
+ const slot2Binding = getChatViewEntityBinding(state?.slotBindings.slot2);
+
+ // Both channels stay bound at once — the second did not replace the first.
+ expect(slot1Binding?.kind).toBe('channel');
+ expect(slot1Binding?.source).toBe(primaryChannel);
+ expect(slot2Binding?.kind).toBe('channel');
+ expect(slot2Binding?.source).toBe(secondaryChannel);
+ expect(state?.activeView).toBe('channels');
+ });
+
+ // Base policy: opening a reply thread while both slots are occupied by channels must NOT evict
+ // either channel. The primary/anchor channel stays put, and — since the only remaining target is
+ // the occupied secondary slot — the thread STACKS as a layer over the 2nd channel instead of
+ // replacing it. `close`/`popLayer` on that slot restores the 2nd channel.
+ it('layers a thread over the secondary channel instead of evicting it', () => {
+ const primaryChannel = makeChannel('messaging:primary');
+ const secondaryChannel = makeChannel('messaging:secondary');
+ const thread = makeThread('thread-from-primary');
+ let capturedController: LayoutController | undefined;
+
+ const Harness = () => {
+ const navigation = useChatViewNavigation();
+ const { layoutController } = useChatViewContext();
+ capturedController = layoutController;
+
+ return (
+ <>
+
+
+
+ >
+ );
+ };
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText('open-primary'));
+ fireEvent.click(screen.getByText('open-secondary'));
+ fireEvent.click(screen.getByText('open-thread'));
+
+ const state = viewState(capturedController);
+ const slot1Binding = getChatViewEntityBinding(state?.slotBindings.slot1);
+ const slot2Binding = getChatViewEntityBinding(state?.slotBindings.slot2);
+ const slot2TopLayer = getChatViewEntityBinding(state?.slotLayers?.slot2?.[0]);
+
+ // Primary channel stays put in slot1; the 2nd channel stays as slot2's base with the thread
+ // stacked on top as a layer.
+ expect(slot1Binding?.kind).toBe('channel');
+ expect(slot1Binding?.source).toBe(primaryChannel);
+ expect(slot2Binding?.kind).toBe('channel');
+ expect(slot2Binding?.source).toBe(secondaryChannel);
+ expect(state?.slotLayers?.slot2).toHaveLength(1);
+ expect(slot2TopLayer?.kind).toBe('thread');
+ expect(slot2TopLayer?.source).toBe(thread);
+ });
+
+ // Regression: `open(binding, { additive: true })` — used by ctrl/⌘-click on channel-list and
+ // search results — opens beside the current channel instead of replacing it. Without
+ // `additive`, a same-kind open resolves to the occupied primary slot and replaces it; with
+ // `additive` it must skip that and land in the free secondary slot.
+ it('opens additively into the secondary slot without replacing the primary channel', () => {
+ const primaryChannel = makeChannel('messaging:primary');
+ const secondaryChannel = makeChannel('messaging:secondary');
+ let capturedController: LayoutController | undefined;
+
+ const Harness = () => {
+ const navigation = useChatViewNavigation();
+ const { layoutController } = useChatViewContext();
+ capturedController = layoutController;
+
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText('open-primary'));
+ fireEvent.click(screen.getByText('open-additive'));
+
+ const state = viewState(capturedController);
+ const slot1Binding = getChatViewEntityBinding(state?.slotBindings.slot1);
+ const slot2Binding = getChatViewEntityBinding(state?.slotBindings.slot2);
+
+ // The additive open landed in the free secondary slot; the primary is untouched.
+ expect(slot1Binding?.source).toBe(primaryChannel);
+ expect(slot2Binding?.source).toBe(secondaryChannel);
+ });
+
+ it('pushLayer stacks over the base binding and popLayer removes it', () => {
+ const channel = makeChannel('messaging:layer-base');
+ 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('push-layer'));
+
+ const layered = viewState(capturedController);
+ // Base binding is untouched; the profile lives in the layer stack on top of it.
+ expect(getChatViewEntityBinding(layered?.slotBindings.slot1)?.kind).toBe('channel');
+ expect(layered?.slotLayers?.slot1).toHaveLength(1);
+ expect(getChatViewEntityBinding(layered?.slotLayers?.slot1?.[0])?.kind).toBe(
+ 'userProfile',
+ );
+
+ fireEvent.click(screen.getByText('pop-layer'));
+
+ const popped = viewState(capturedController);
+ expect(popped?.slotLayers?.slot1).toBeUndefined();
+ expect(getChatViewEntityBinding(popped?.slotBindings.slot1)?.kind).toBe('channel');
+ });
+
+ it('close pops the top layer first and releases the base only when no layers remain', () => {
+ const channel = makeChannel('messaging:layer-close');
+ 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('push-layer'));
+
+ // First close: pops the layer, base channel stays bound.
+ fireEvent.click(screen.getByText('close'));
+ const afterFirstClose = viewState(capturedController);
+ expect(afterFirstClose?.slotLayers?.slot1).toBeUndefined();
+ expect(getChatViewEntityBinding(afterFirstClose?.slotBindings.slot1)?.kind).toBe(
+ 'channel',
+ );
+
+ // Second close: no layers left, so the base binding is released.
+ fireEvent.click(screen.getByText('close'));
+ expect(viewState(capturedController)?.slotBindings.slot1).toBeUndefined();
+ });
+
+ // Regression: `open(binding, { additive: true, layer: true })` stacks the binding as a layer on
+ // top of the (occupied) secondary slot instead of evicting it, and does NOT run the channel's
+ // dependent thread-release — so ⌘/ctrl-clicking a channel beside an open reply thread covers the
+ // thread without closing it.
+ it('layers a channel over an open thread without releasing it', () => {
+ const channelA = makeChannel('messaging:layer-primary');
+ const channelB = makeChannel('messaging:layer-secondary');
+ const thread = makeThread('thread-under-channel-layer');
+ 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'));
+ fireEvent.click(screen.getByText('layer-channel'));
+
+ const state = viewState(capturedController);
+ // slot2's base binding is still the thread; the 2nd channel sits on top as a layer.
+ expect(getChatViewEntityBinding(state?.slotBindings.slot2)?.kind).toBe('thread');
+ expect(state?.slotLayers?.slot2).toHaveLength(1);
+ const topLayer = getChatViewEntityBinding(state?.slotLayers?.slot2?.[0]);
+ expect(topLayer?.kind).toBe('channel');
+ expect(topLayer?.source).toBe(channelB);
+ // The primary channel is untouched (dependent thread-release did not fire).
+ expect(getChatViewEntityBinding(state?.slotBindings.slot1)?.source).toBe(channelA);
+ });
+
+ it('opens channel and thread into configured slotNames in order', () => {
+ 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 = viewState(capturedController);
+ expect(openThreadState?.availableSlots).toEqual(['list', 'main']);
+ expect(getChatViewEntityBinding(openThreadState?.slotBindings.list)?.kind).toBe(
+ 'channel',
+ );
+ expect(getChatViewEntityBinding(openThreadState?.slotBindings.main)?.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..b36785bcb4
--- /dev/null
+++ b/src/components/ChatView/__tests__/useSlotEntity.test.tsx
@@ -0,0 +1,345 @@
+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 } from '../ChatView';
+import { ThreadSlot } from '../../Thread';
+import {
+ getLayoutViewState,
+ useSlotChannel,
+ useSlotEntity,
+ useSlotThread,
+ useSlotTopLayerEntity,
+} 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';
+
+vi.mock('../../Channel/Channel', () => ({
+ Channel: ({
+ channel,
+ children,
+ }: {
+ channel: StreamChannel;
+ children?: React.ReactNode;
+ }) => (
+
+ {channel.cid}
+ {children}
+
+ ),
+}));
+
+vi.mock('../../Threads', () => ({
+ ThreadProvider: ({ children }: { children?: React.ReactNode }) => (
+ <>{children ?? null}>
+ ),
+}));
+
+vi.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: vi.fn(),
+ setQueryInProgress: vi.fn(),
+ },
+ client: {
+ threads: {
+ state: new StateStore({
+ unreadThreadCount: 0,
+ }),
+ },
+ },
+ getAppSettings: vi.fn(() => null),
+ latestMessageDatesByChannels: {},
+ openMobileNav: vi.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.bind('slot1', {
+ key: `channel:${channelB.cid}`,
+ payload: { key: channelB.cid, kind: 'channel', source: channelB },
+ });
+ layoutController.bind('slot2', {
+ key: `channel:${channelA.cid}`,
+ payload: { key: channelA.cid, kind: 'channel', source: channelA },
+ });
+ layoutController.bind('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');
+ });
+
+ it('resolves the top layer via useSlotTopLayerEntity without shadowing the base binding', () => {
+ const thread = makeThread('thread-under-layer');
+
+ const layoutController = new LayoutController({
+ initialState: {
+ availableSlots: ['slot1'],
+ },
+ });
+
+ // Base binding: a thread. Layer on top: a member profile.
+ layoutController.bind('slot1', {
+ key: `thread:${thread.id}`,
+ payload: { key: thread.id, kind: 'thread', source: thread },
+ });
+ layoutController.pushLayer('slot1', {
+ key: 'userProfile:u1',
+ payload: { key: 'userProfile:u1', kind: 'userProfile', source: { userId: 'u1' } },
+ });
+
+ const Harness = () => {
+ const topProfile = useSlotTopLayerEntity({ kind: 'userProfile', slot: 'slot1' });
+ // The base binding (thread) is unchanged — a layer never rewrites `slotBindings`.
+ const baseThread = useSlotThread({ slot: 'slot1' });
+ // Reading the profile as a base binding must NOT find it (it lives in the layer stack).
+ const profileAsBase = useSlotEntity({ kind: 'userProfile', slot: 'slot1' });
+
+ return (
+ <>
+ {topProfile?.userId}
+ {baseThread?.id}
+ {profileAsBase?.userId ?? 'none'}
+ >
+ );
+ };
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('top-profile')).toHaveTextContent('u1');
+ expect(screen.getByTestId('base-thread')).toHaveTextContent('thread-under-layer');
+ expect(screen.getByTestId('profile-as-base')).toHaveTextContent('none');
+ });
+
+ it('returns undefined from useSlotTopLayerEntity when the top layer kind does not match', () => {
+ const layoutController = new LayoutController({
+ initialState: {
+ availableSlots: ['slot1'],
+ },
+ });
+
+ layoutController.pushLayer('slot1', {
+ key: 'userProfile:u2',
+ payload: { key: 'userProfile:u2', kind: 'userProfile', source: { userId: 'u2' } },
+ });
+
+ const Harness = () => {
+ const asThread = useSlotTopLayerEntity({ kind: 'thread', slot: 'slot1' });
+ const asProfile = useSlotTopLayerEntity({ kind: 'userProfile', slot: 'slot1' });
+ return (
+ <>
+ {asThread ? 'match' : 'none'}
+ {asProfile?.userId ?? 'none'}
+ >
+ );
+ };
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('as-thread')).toHaveTextContent('none');
+ expect(screen.getByTestId('as-profile')).toHaveTextContent('u2');
+ });
+});
+
+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.bind('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('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.bind('slot1', {
+ key: `thread:${thread.id}`,
+ payload: { key: thread.id, kind: 'thread', source: thread },
+ });
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('mocked-thread-component')).toBeInTheDocument();
+ });
+
+ it('hides explicit slot when ThreadSlot has no thread', async () => {
+ const layoutController = new LayoutController({
+ initialState: {
+ availableSlots: ['slot1'],
+ hiddenSlots: { slot1: false },
+ },
+ });
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ getLayoutViewState(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.bind('slot1', {
+ key: `thread:${thread.id}`,
+ payload: { key: thread.id, kind: 'thread', source: thread },
+ });
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ getLayoutViewState(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..a00a604ca6
--- /dev/null
+++ b/src/components/ChatView/hooks/useLayoutViewState.ts
@@ -0,0 +1,39 @@
+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 => {
+ const layout = state.layouts?.[view];
+ return {
+ availableSlots: [...(layout?.availableSlots ?? [])],
+ hiddenSlots: { ...(layout?.hiddenSlots ?? {}) },
+ slotBindings: { ...(layout?.slotBindings ?? {}) },
+ slotForwardHistory: { ...(layout?.slotForwardHistory ?? {}) },
+ slotHistory: { ...(layout?.slotHistory ?? {}) },
+ slotLayers: { ...(layout?.slotLayers ?? {}) },
+ slotMeta: { ...(layout?.slotMeta ?? {}) },
+ slotNames: layout?.slotNames ? [...layout.slotNames] : 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..1f78a142a9
--- /dev/null
+++ b/src/components/ChatView/hooks/useSlotEntity.ts
@@ -0,0 +1,236 @@
+import { getChatViewEntityBinding } from '../ChatView';
+import { useLayoutViewState } from './useLayoutViewState';
+
+import type { ChatView, 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;
+ /**
+ * Optional explicit view to read slots from.
+ *
+ * - when omitted: the **active** view's slots are read
+ * - when provided: that view's slots are read regardless of which view is active
+ * (its layout state is retained across view switches). Useful for cross-view
+ * concerns like URL sync that must not clear when another view is focused.
+ */
+ view?: ChatView;
+};
+
+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,
+ view,
+}: UseSlotEntityOptions): SlotEntitySourceByKind | undefined => {
+ const { availableSlots, slotBindings } = useLayoutViewState(view);
+
+ 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;
+};
+
+/**
+ * Like {@link useSlotEntity}, but resolves the source of the **topmost layer** on a slot (the last
+ * entry of `slotLayers[slot]`) rather than its base binding — when that layer's `kind` matches.
+ *
+ * Layers stack above the base binding and cover it while it stays mounted (see
+ * `ChatViewNavigation.pushLayer`). Read the base with `useSlotEntity`/`useSlotChannel`/… and the
+ * overlay on top with this hook, then render the overlay covering the base.
+ *
+ * @example
+ * ```tsx
+ * const profile = useSlotTopLayerEntity({ kind: 'userProfile', slot: 'main-thread' });
+ * // profile is { userId } | undefined — present while a userProfile layer covers the slot
+ * ```
+ */
+export const useSlotTopLayerEntity = ({
+ kind,
+ slot,
+ view,
+}: UseSlotEntityOptions): SlotEntitySourceByKind | undefined => {
+ const { availableSlots, slotLayers } = useLayoutViewState(view);
+
+ for (const candidateSlot of resolveCandidateSlots(slot, availableSlots)) {
+ const layers = slotLayers?.[candidateSlot];
+ const topBinding = layers?.[layers.length - 1];
+ const entity = getChatViewEntityBinding(topBinding);
+ if (isEntityOfKind(entity, kind)) {
+ // Safe due to discriminant check on `kind` above.
+ return entity.source as SlotEntitySourceByKind;
+ }
+ }
+
+ return undefined;
+};
+
+/**
+ * Resolves **every** matching ChatView entity binding as `{ slot, source }` pairs
+ * (in active-view `availableSlots` order, or only `slot` when provided).
+ *
+ * Prefer this over `useSlotEntity` when several slots can hold the same kind (e.g.
+ * two channels side by side) — it never collapses the set to "the first match".
+ *
+ * @example
+ * ```tsx
+ * const channels = useSlotEntities({ kind: 'channel' });
+ * // channels is Array<{ slot: SlotName; source: Channel }>
+ * ```
+ */
+export const useSlotEntities = ({
+ kind,
+ slot,
+ view,
+}: UseSlotEntityOptions): Array<{
+ slot: SlotName;
+ source: SlotEntitySourceByKind;
+}> => {
+ const { availableSlots, slotBindings } = useLayoutViewState(view);
+
+ return resolveCandidateSlots(slot, availableSlots).reduce<
+ Array<{ slot: SlotName; source: SlotEntitySourceByKind }>
+ >((matches, candidateSlot) => {
+ const entity = getChatViewEntityBinding(slotBindings[candidateSlot]);
+ if (isEntityOfKind(entity, kind)) {
+ // Safe due to discriminant check on `kind` above.
+ matches.push({
+ slot: candidateSlot,
+ source: entity.source as SlotEntitySourceByKind,
+ });
+ }
+ return matches;
+ }, []);
+};
+
+/**
+ * Convenience wrapper for `useSlotEntity({ kind: 'channel' })` — the channel in a
+ * single slot. Pass `slot` to be precise; omitting it returns the first channel slot
+ * (only meaningful for a single-channel workspace). For multiple channels, use
+ * `useSlotChannels`.
+ *
+ * @example
+ * ```tsx
+ * const channel = useSlotChannel({ slot: 'main' });
+ * ```
+ */
+export const useSlotChannel = (options?: { slot?: SlotName; view?: ChatView }) =>
+ useSlotEntity({ kind: 'channel', slot: options?.slot, view: options?.view });
+
+/**
+ * Every slot currently holding a channel, as `{ channel, slot }` pairs. Robust for
+ * side-by-side channels — the caller renders one panel per entry (and, for a
+ * single-channel workspace, gets an array of length ≤ 1).
+ *
+ * @example
+ * ```tsx
+ * useSlotChannels().map(({ channel, slot }) => )
+ * ```
+ */
+export const useSlotChannels = (options?: { slot?: SlotName; view?: ChatView }) =>
+ useSlotEntities({ kind: 'channel', slot: options?.slot, view: options?.view }).map(
+ ({ slot, source }) => ({
+ channel: source,
+ slot,
+ }),
+ );
+
+/**
+ * Convenience wrapper for `useSlotEntity({ kind: 'thread' })`.
+ *
+ * @example
+ * ```tsx
+ * const thread = useSlotThread();
+ * ```
+ */
+export const useSlotThread = (options?: { slot?: SlotName; view?: ChatView }) =>
+ useSlotEntity({ kind: 'thread', slot: options?.slot, view: options?.view });
+
+/**
+ * Every slot currently holding a thread, as `{ thread, slot }` pairs — the thread
+ * analog of `useSlotChannels`. The caller renders one thread panel per entry.
+ *
+ * @example
+ * ```tsx
+ * useSlotThreads().map(({ thread, slot }) => (
+ *
+ * ))
+ * ```
+ */
+export const useSlotThreads = (options?: { slot?: SlotName; view?: ChatView }) =>
+ useSlotEntities({ kind: 'thread', slot: options?.slot, view: options?.view }).map(
+ ({ slot, source }) => ({
+ slot,
+ thread: source,
+ }),
+ );
diff --git a/src/components/ChatView/index.tsx b/src/components/ChatView/index.tsx
index c9582c20c1..39ebfc16d2 100644
--- a/src/components/ChatView/index.tsx
+++ b/src/components/ChatView/index.tsx
@@ -1 +1,5 @@
export * from './ChatView';
+export * from './ChatViewNavigationContext';
+export * from './layoutController/layoutControllerTypes';
+export * from './layoutController/serialization';
+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..7dfc8803fc
--- /dev/null
+++ b/src/components/ChatView/layout/Slot.tsx
@@ -0,0 +1,77 @@
+import clsx from 'clsx';
+import React from 'react';
+
+import { useLayoutViewState } from '../hooks/useLayoutViewState';
+import { getChatViewEntityBinding } from '../slotBinding';
+import { renderSlotFromRegistry, useSlotRegistry } from '../slotRegistry';
+
+import type { ReactNode } from 'react';
+
+export type SlotProps = {
+ children?: ReactNode;
+ className?: string;
+ slot: string;
+};
+
+export const Slot = ({ children, className, slot }: SlotProps) => {
+ const { hiddenSlots, slotBindings, slotLayers } = useLayoutViewState();
+ const registry = useSlotRegistry();
+ const hidden = !!hiddenSlots?.[slot];
+ const layers = slotLayers?.[slot] ?? [];
+
+ // Explicit children win (legacy claim-and-render wrappers); otherwise dispatch
+ // on the slot's current binding via the registry.
+ const baseContent =
+ children ??
+ renderSlotFromRegistry(getChatViewEntityBinding(slotBindings[slot]), slot, registry);
+
+ return (
+
+ {layers.length === 0 ? (
+ // Fast path: no layers — render the base content directly (unchanged structure).
+ baseContent
+ ) : (
+ // Layered: base + each layer kept mounted; only the topmost is shown. Non-top layers get
+ // the `hidden` attribute (display:none) so their subtree state is preserved; the visible
+ // layer uses `display: contents` so it lays out exactly as a direct child would. Each entry
+ // is keyed by its binding identity (`LayoutSlotBinding.key`, the same key the controller
+ // dedupes on) — not the array index — so a layer keeps its subtree as the stack changes.
+ <>
+ {[
+ { key: 'base', node: baseContent },
+ ...layers.map((layer) => {
+ const entity = getChatViewEntityBinding(layer);
+ return {
+ key: layer.key ?? `layer:${entity?.kind ?? 'unknown'}`,
+ node: renderSlotFromRegistry(entity, slot, registry),
+ };
+ }),
+ ].map(({ key, node }, index, all) => {
+ const isTop = index === all.length - 1;
+ return (
+
+ {node}
+
+ );
+ })}
+ >
+ )}
+
+ );
+};
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..3fab58e768
--- /dev/null
+++ b/src/components/ChatView/layoutController/LayoutController.ts
@@ -0,0 +1,619 @@
+import { StateStore } from 'stream-chat';
+
+import type {
+ ChatViewLayoutState,
+ ChatViewLayoutViewState,
+ CreateLayoutControllerOptions,
+ DuplicateEntityPolicy,
+ LayoutController as LayoutControllerApi,
+ LayoutRuntimeState,
+ LayoutSlotBinding,
+ OpenOptions,
+ OpenResult,
+ SlotName,
+} from './layoutControllerTypes';
+
+const DEFAULT_LAYOUT_STATE: ChatViewLayoutState = {
+ activeView: 'channels',
+ layouts: {},
+};
+
+/** Build a full per-layout runtime state, defaulting any field left unset.
+ * Handy for `initialState` callers that only want to seed a couple of fields. */
+export const createLayoutRuntimeState = (
+ partial: Partial = {},
+): LayoutRuntimeState => ({
+ availableSlots: partial.availableSlots ? [...partial.availableSlots] : [],
+ hiddenSlots: { ...(partial.hiddenSlots ?? {}) },
+ slotBindings: { ...(partial.slotBindings ?? {}) },
+ slotForwardHistory: { ...(partial.slotForwardHistory ?? {}) },
+ slotHistory: { ...(partial.slotHistory ?? {}) },
+ slotLayers: { ...(partial.slotLayers ?? {}) },
+ slotMeta: { ...(partial.slotMeta ?? {}) },
+ slotNames: partial.slotNames ? [...partial.slotNames] : undefined,
+});
+
+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 => {
+ const layout = state.layouts?.[view];
+ return {
+ availableSlots: [...(layout?.availableSlots ?? [])],
+ hiddenSlots: { ...(layout?.hiddenSlots ?? {}) },
+ slotBindings: { ...(layout?.slotBindings ?? {}) },
+ slotForwardHistory: { ...(layout?.slotForwardHistory ?? {}) },
+ slotHistory: { ...(layout?.slotHistory ?? {}) },
+ slotLayers: { ...(layout?.slotLayers ?? {}) },
+ slotMeta: { ...(layout?.slotMeta ?? {}) },
+ slotNames: layout?.slotNames ? [...layout.slotNames] : undefined,
+ };
+};
+
+const mergeViewState = (
+ state: ChatViewLayoutState,
+ nextViewState: ChatViewLayoutViewState,
+ view = state.activeView,
+): ChatViewLayoutState => ({
+ ...state,
+ layouts: {
+ ...(state.layouts ?? {}),
+ [view]: {
+ availableSlots: [...nextViewState.availableSlots],
+ hiddenSlots: { ...nextViewState.hiddenSlots },
+ slotBindings: { ...nextViewState.slotBindings },
+ slotForwardHistory: { ...nextViewState.slotForwardHistory },
+ slotHistory: { ...nextViewState.slotHistory },
+ slotLayers: { ...(nextViewState.slotLayers ?? {}) },
+ slotMeta: { ...nextViewState.slotMeta },
+ slotNames: nextViewState.slotNames ? [...nextViewState.slotNames] : undefined,
+ },
+ },
+});
+
+const buildInitialState = (
+ partialState?: CreateLayoutControllerOptions['initialState'],
+): ChatViewLayoutState => {
+ const activeView = partialState?.activeView ?? DEFAULT_LAYOUT_STATE.activeView;
+
+ // Flat single-view shorthand: fold top-level runtime fields into the active view's
+ // layout when it isn't provided explicitly via `layouts`.
+ const {
+ availableSlots,
+ hiddenSlots,
+ slotBindings,
+ slotForwardHistory,
+ slotHistory,
+ slotMeta,
+ slotNames,
+ } = partialState ?? {};
+ const hasFlatViewFields = Boolean(
+ availableSlots ||
+ hiddenSlots ||
+ slotBindings ||
+ slotForwardHistory ||
+ slotHistory ||
+ slotMeta ||
+ slotNames,
+ );
+
+ const normalized: ChatViewLayoutState = {
+ ...DEFAULT_LAYOUT_STATE,
+ ...partialState,
+ activeView,
+ layouts: {
+ ...(DEFAULT_LAYOUT_STATE.layouts ?? {}),
+ ...(hasFlatViewFields
+ ? {
+ [activeView]: createLayoutRuntimeState({
+ availableSlots,
+ hiddenSlots,
+ slotBindings,
+ slotForwardHistory,
+ slotHistory,
+ slotMeta,
+ slotNames,
+ }),
+ }
+ : {}),
+ ...(partialState?.layouts ?? {}),
+ },
+ };
+
+ const initialViewState = toViewState(normalized, activeView);
+ const next = mergeViewState(normalized, initialViewState, activeView);
+
+ if (
+ !next.layouts?.[activeView]?.availableSlots?.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] &&
+ !viewState.slotLayers?.[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];
+
+ const nextSlotLayers = { ...(viewState.slotLayers ?? {}) };
+ delete nextSlotLayers[slot];
+
+ return {
+ ...viewState,
+ slotBindings: nextSlotBindings,
+ slotForwardHistory: nextSlotForwardHistory,
+ slotHistory: nextSlotHistory,
+ slotLayers: nextSlotLayers,
+ slotMeta: nextSlotMeta,
+ };
+};
+
+// step 2 — the single slot resolver lives in the navigation layer (ChatViewNavigationContext).
+// openInLayout is pure mechanism: it accepts an already-resolved, available target slot (an
+// occupied slot is allowed — that is a replace) and otherwise rejects. Callers own resolution.
+const resolveOpenTargetSlot = (
+ activeViewState: ChatViewLayoutViewState,
+ options: OpenOptions | undefined,
+): SlotName | undefined => {
+ const requestedSlot = options?.targetSlot;
+ if (!requestedSlot) return undefined;
+ return activeViewState.availableSlots.includes(requestedSlot)
+ ? requestedSlot
+ : undefined;
+};
+
+export class LayoutController implements LayoutControllerApi {
+ private readonly duplicateEntityPolicy: DuplicateEntityPolicy;
+ private readonly resolveDuplicateEntity?: CreateLayoutControllerOptions['resolveDuplicateEntity'];
+ state: StateStore;
+
+ constructor(options: CreateLayoutControllerOptions = {}) {
+ const {
+ duplicateEntityPolicy = DEFAULT_DUPLICATE_ENTITY_POLICY,
+ initialState,
+ resolveDuplicateEntity,
+ state = new StateStore(buildInitialState(initialState)),
+ } = options;
+
+ this.duplicateEntityPolicy = duplicateEntityPolicy;
+ this.resolveDuplicateEntity = resolveDuplicateEntity;
+ 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,
+ },
+ });
+ });
+ };
+
+ // step 4 — bind/release are the mechanism primitives. `bind` places a binding into a slot
+ // (an occupied slot is replaced); `release` frees a slot. Higher-level open/close in the
+ // navigation layer are built on these; history navigation is goBack/goForward, kept separate.
+ bind: LayoutControllerApi['bind'] = (slot, binding) => {
+ this.state.next((current) => {
+ const activeViewState = toViewState(current);
+ const nextViewState = clearSlotForwardHistory(
+ upsertSlotBinding(activeViewState, slot, binding),
+ slot,
+ );
+
+ return mergeViewState(current, nextViewState);
+ });
+ };
+
+ release: LayoutControllerApi['release'] = (slot) => {
+ this.state.next((current) => {
+ const activeViewState = toViewState(current);
+ return mergeViewState(current, clearSlotBinding(activeViewState, slot));
+ });
+ };
+
+ // Layers stack *above* a slot's base binding (see `slotLayers`). Push/pop keep every layer
+ // (and the base beneath) mounted; only the topmost is shown by the renderer, so popping a
+ // layer reveals what's underneath at its exact preserved state.
+ pushLayer: LayoutControllerApi['pushLayer'] = (slot, binding) => {
+ this.state.next((current) => {
+ const activeViewState = toViewState(current);
+ const slotLayers = activeViewState.slotLayers?.[slot] ?? [];
+ return mergeViewState(current, {
+ ...activeViewState,
+ slotLayers: {
+ ...(activeViewState.slotLayers ?? {}),
+ [slot]: [...slotLayers, binding],
+ },
+ });
+ });
+ };
+
+ popLayer: LayoutControllerApi['popLayer'] = (slot) => {
+ this.state.next((current) => {
+ const activeViewState = toViewState(current);
+ const slotLayers = activeViewState.slotLayers?.[slot];
+ if (!slotLayers?.length) return current;
+
+ const nextSlotLayers = { ...(activeViewState.slotLayers ?? {}) };
+ const remaining = slotLayers.slice(0, -1);
+ if (remaining.length) {
+ nextSlotLayers[slot] = remaining;
+ } else {
+ delete nextSlotLayers[slot];
+ }
+
+ return mergeViewState(current, {
+ ...activeViewState,
+ slotLayers: nextSlotLayers,
+ });
+ });
+ };
+
+ 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(activeViewState, openOptions);
+
+ 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..034e4d7795
--- /dev/null
+++ b/src/components/ChatView/layoutController/layoutControllerTypes.ts
@@ -0,0 +1,152 @@
+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 ChatViewLayoutViewState = {
+ availableSlots: SlotName[];
+ hiddenSlots: Record;
+ slotBindings: Record;
+ slotHistory: Record;
+ slotForwardHistory: Record;
+ /**
+ * Per-slot **layer stack** rendered *above* the slot's base `slotBindings[slot]`. Unlike
+ * history (past bindings, unmounted) a layer coexists with the base and the layers beneath it —
+ * all kept mounted, only the topmost visible — so revealing a lower layer restores its exact
+ * state (e.g. a thread's scroll position). Index 0 is the bottom layer, the last entry is the
+ * top/active one. Ephemeral: intentionally left out of serialization so a reload restores only
+ * the base bindings, never a transient overlay.
+ */
+ slotLayers?: Record;
+ slotMeta: Record;
+ slotNames?: SlotName[];
+};
+
+// D7 — per-layout runtime state. A "view" is just a named layout; each layout
+// keeps its own slot runtime state (bindings/history/hidden/…). Structurally
+// identical to the projected `ChatViewLayoutViewState`.
+export type LayoutRuntimeState = ChatViewLayoutViewState;
+
+export type ChatViewLayoutState = {
+ activeView: ChatView;
+ maxSlots?: number;
+ minSlots?: number;
+ // One map keyed by layout id (was seven parallel `*ByView` maps).
+ layouts?: Partial>;
+};
+
+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' }
+ | { slot: SlotName; status: 'layered' }
+ | { 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 SerializedLayoutRuntimeState = {
+ availableSlots: SlotName[];
+ hiddenSlots: Record;
+ slotBindings: Record;
+ slotForwardHistory: Record;
+ slotHistory: Record;
+ slotMeta: Record;
+ slotNames?: SlotName[];
+};
+
+export type ChatViewLayoutSnapshot = {
+ activeView: LayoutView;
+ layouts: 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;
+};
+
+// `initialState` also accepts a flat single-view shorthand: the per-view runtime fields
+// (availableSlots/hiddenSlots/slotBindings/…) may be given at the top level and are folded
+// into the active view's layout (an explicit `layouts[activeView]` still wins).
+export type CreateLayoutControllerInitialState = Partial &
+ Partial;
+
+export type CreateLayoutControllerOptions = {
+ duplicateEntityPolicy?: DuplicateEntityPolicy;
+ initialState?: CreateLayoutControllerInitialState;
+ resolveDuplicateEntity?: ResolveDuplicateEntity;
+ state?: StateStore;
+};
+
+export type LayoutController = {
+ bind: (slot: SlotName, binding: LayoutSlotBinding) => void;
+ /** Push a binding onto `slot`'s layer stack (above its base + existing layers). */
+ pushLayer: (slot: SlotName, binding: LayoutSlotBinding) => void;
+ /** Pop the top layer off `slot`'s layer stack (no-op when the stack is empty). */
+ popLayer: (slot: SlotName) => void;
+ release: (slot: SlotName) => void;
+ setAvailableSlots: (slots: SlotName[]) => void;
+ setSlotNames: (slots?: 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..9b6351c5c1
--- /dev/null
+++ b/src/components/ChatView/layoutController/serialization.ts
@@ -0,0 +1,164 @@
+import type {
+ ChatViewLayoutSnapshot,
+ ChatViewLayoutState,
+ DeserializeLayoutEntityBinding,
+ LayoutController,
+ LayoutRuntimeState,
+ RestoreLayoutStateOptions,
+ SerializedLayoutRuntimeState,
+ SerializedLayoutSlotBinding,
+ SerializeLayoutEntityBinding,
+ SerializeLayoutStateOptions,
+} from './layoutControllerTypes';
+import type { ChatView } from '../ChatView';
+
+const defaultSerializeEntityBinding: SerializeLayoutEntityBinding = (binding) => ({
+ key: binding.key,
+ payload: binding.payload,
+});
+
+const defaultDeserializeEntityBinding: DeserializeLayoutEntityBinding = (binding) =>
+ binding;
+
+const serializeBindings = (
+ bindings: LayoutRuntimeState['slotBindings'],
+ serializeEntityBinding: SerializeLayoutEntityBinding,
+) =>
+ Object.entries(bindings ?? {}).reduce<
+ Record
+ >((acc, [slot, binding]) => {
+ acc[slot] = binding ? serializeEntityBinding(binding) : undefined;
+ return acc;
+ }, {});
+
+const serializeHistory = (
+ history: LayoutRuntimeState['slotHistory'],
+ serializeEntityBinding: SerializeLayoutEntityBinding,
+) =>
+ Object.entries(history ?? {}).reduce<
+ Record
+ >((acc, [slot, entities]) => {
+ if (!entities?.length) {
+ acc[slot] = undefined;
+ return acc;
+ }
+ const serialized = entities
+ .map(serializeEntityBinding)
+ .filter((binding): binding is SerializedLayoutSlotBinding => !!binding);
+ acc[slot] = serialized.length ? serialized : undefined;
+ return acc;
+ }, {});
+
+const serializeRuntimeState = (
+ runtime: LayoutRuntimeState,
+ serializeEntityBinding: SerializeLayoutEntityBinding,
+): SerializedLayoutRuntimeState => ({
+ availableSlots: [...runtime.availableSlots],
+ hiddenSlots: { ...runtime.hiddenSlots },
+ slotBindings: serializeBindings(runtime.slotBindings, serializeEntityBinding),
+ slotForwardHistory: serializeHistory(
+ runtime.slotForwardHistory,
+ serializeEntityBinding,
+ ),
+ slotHistory: serializeHistory(runtime.slotHistory, serializeEntityBinding),
+ slotMeta: { ...runtime.slotMeta },
+ slotNames: runtime.slotNames ? [...runtime.slotNames] : undefined,
+});
+
+const deserializeBindings = (
+ bindings: SerializedLayoutRuntimeState['slotBindings'],
+ deserializeEntityBinding: DeserializeLayoutEntityBinding,
+) =>
+ Object.entries(bindings ?? {}).reduce(
+ (acc, [slot, binding]) => {
+ acc[slot] = binding ? deserializeEntityBinding(binding) : undefined;
+ return acc;
+ },
+ {},
+ );
+
+const deserializeHistory = (
+ history: SerializedLayoutRuntimeState['slotHistory'],
+ deserializeEntityBinding: DeserializeLayoutEntityBinding,
+) =>
+ Object.entries(history ?? {}).reduce(
+ (acc, [slot, entities]) => {
+ if (!entities?.length) {
+ acc[slot] = undefined;
+ return acc;
+ }
+ const deserialized = entities
+ .map(deserializeEntityBinding)
+ .filter((binding): binding is NonNullable => !!binding);
+ acc[slot] = deserialized.length ? deserialized : undefined;
+ return acc;
+ },
+ {},
+ );
+
+const deserializeRuntimeState = (
+ runtime: SerializedLayoutRuntimeState,
+ deserializeEntityBinding: DeserializeLayoutEntityBinding,
+): LayoutRuntimeState => ({
+ availableSlots: [...runtime.availableSlots],
+ hiddenSlots: { ...runtime.hiddenSlots },
+ slotBindings: deserializeBindings(runtime.slotBindings, deserializeEntityBinding),
+ slotForwardHistory: deserializeHistory(
+ runtime.slotForwardHistory,
+ deserializeEntityBinding,
+ ),
+ slotHistory: deserializeHistory(runtime.slotHistory, deserializeEntityBinding),
+ slotMeta: { ...runtime.slotMeta },
+ slotNames: runtime.slotNames ? [...runtime.slotNames] : undefined,
+});
+
+export const serializeLayoutState = (
+ state: ChatViewLayoutState,
+ options: SerializeLayoutStateOptions = {},
+): ChatViewLayoutSnapshot => {
+ const serializeEntityBinding =
+ options.serializeEntityBinding ?? defaultSerializeEntityBinding;
+
+ const layouts = Object.entries(state.layouts ?? {}).reduce<
+ ChatViewLayoutSnapshot['layouts']
+ >((acc, [view, runtime]) => {
+ if (runtime) {
+ acc[view as ChatView] = serializeRuntimeState(runtime, serializeEntityBinding);
+ }
+ return acc;
+ }, {});
+
+ return { activeView: state.activeView, layouts };
+};
+
+export const restoreLayoutState = (
+ snapshot: ChatViewLayoutSnapshot,
+ options: RestoreLayoutStateOptions = {},
+): ChatViewLayoutState => {
+ const deserializeEntityBinding =
+ options.deserializeEntityBinding ?? defaultDeserializeEntityBinding;
+
+ const layouts = Object.entries(snapshot.layouts ?? {}).reduce<
+ NonNullable
+ >((acc, [view, runtime]) => {
+ if (runtime) {
+ acc[view as ChatView] = deserializeRuntimeState(runtime, deserializeEntityBinding);
+ }
+ return acc;
+ }, {});
+
+ return { activeView: snapshot.activeView, layouts };
+};
+
+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/slotBinding.ts b/src/components/ChatView/slotBinding.ts
new file mode 100644
index 0000000000..2a0885a920
--- /dev/null
+++ b/src/components/ChatView/slotBinding.ts
@@ -0,0 +1,95 @@
+// Leaf module: entity-binding primitives shared by ChatView, the generic
+// dispatcher, and the slot renderer registry. Kept free of ChatView.tsx *value*
+// imports so `Slot`/`slotRegistry` can consume it without an import cycle
+// (ChatView -> WorkspaceLayout -> Slot -> slotRegistry -> here).
+
+import type { Channel as StreamChannel, Thread } from 'stream-chat';
+import type { ReactNode } from 'react';
+import type { ChatView } from './ChatView';
+import type {
+ LayoutSlotBinding,
+ SlotName,
+} from './layoutController/layoutControllerTypes';
+
+export type UserListEntitySource = {
+ query: string;
+};
+
+// D7 (iii) — list kinds carry no source; the layout that seeds them picks the
+// kind (`channelList` vs `threadList`), so the old `source.view` indirection is gone.
+export type ChannelListEntitySource = Record;
+export type ThreadListEntitySource = Record;
+
+export type SearchResultsEntitySource = {
+ query: string;
+};
+
+export type UserProfileEntitySource = {
+ userId: 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 }
+ | { key?: string; kind: 'userProfile'; source: UserProfileEntitySource };
+
+export type ChatViewEntityKind = ChatViewEntityBinding['kind'];
+
+export type LayoutEntityByKind = Extract<
+ ChatViewEntityBinding,
+ { kind: TKind }
+>;
+
+export type ChatViewSlotRendererProps = {
+ entity: LayoutEntityByKind;
+ slot: string;
+ source: LayoutEntityByKind['source'];
+};
+
+export type ChatViewSlotFallbackProps = {
+ slot: string;
+};
+
+/**
+ * App-facing per-kind render overrides. A thin projection of the renderer
+ * registry: supplying `slotRenderers[kind]` overrides only that kind's
+ * `Component` while inheriting the built-in policy (`persistent`/`view`).
+ */
+export type ChatViewSlotRenderers = Partial<{
+ [TKind in ChatViewEntityKind]: (props: ChatViewSlotRendererProps) => ReactNode;
+}>;
+
+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,
+});
+
+/**
+ * Declarative layout preset (D7). A "view" is a `LayoutDescriptor`: a named slot
+ * structure plus optional bindings to seed into those slots. Switching view =
+ * activating a layout; each layout keeps its own slot state (remembered snapshot).
+ */
+export type LayoutDescriptor = {
+ id: ChatView;
+ slots: SlotName[];
+ initialBindings?: Partial>;
+};
diff --git a/src/components/ChatView/slotRegistry.tsx b/src/components/ChatView/slotRegistry.tsx
new file mode 100644
index 0000000000..4c4f5f9927
--- /dev/null
+++ b/src/components/ChatView/slotRegistry.tsx
@@ -0,0 +1,129 @@
+// Renderer registry: the single home for per-kind slot policy.
+// Each registered `kind` declares how it renders (`Component`) plus the policy
+// the navigation layer reads — `persistent` (nav-rail lists that `hide` rather
+// than `close`) and `view` (which ChatView a freshly-opened kind belongs to).
+//
+// `Component`, `persistent`, and `view` are all consumed by later steps:
+// - `Component` here in step 5 (generic self-lookup);
+// - `persistent` in step 7 (replaces the interim `isListEntityKind` set);
+// - `view` in step 6 (kind -> view coupling for the generic `open`).
+
+import React, { createContext, useContext } from 'react';
+
+import { ChannelNavigation } from '../ChannelList';
+import { ThreadList } from '../Threads/ThreadList';
+
+import type { ReactNode } from 'react';
+import type { ChatView } from './ChatView';
+import type {
+ ChatViewEntityBinding,
+ ChatViewEntityKind,
+ ChatViewSlotRendererProps,
+ ChatViewSlotRenderers,
+} from './slotBinding';
+
+export type SlotKindDefinition = {
+ /**
+ * Pure renderer for this kind — no slot claiming, no layout-state reads.
+ * Optional: a kind may declare only policy (`view`/`persistent`) and leave the
+ * renderer to the app's `slotRenderers` prop (e.g. `channel`/`thread`).
+ */
+ Component?: (props: ChatViewSlotRendererProps) => ReactNode;
+ /**
+ * Persistent kinds (nav-rail lists) are `hide`/`unhide`-only: the resolver
+ * never overwrites them and `close` does not apply. Transient kinds
+ * (channel/thread/profile) are `close`-able and may be replaced. Consumed by
+ * step 7.
+ */
+ persistent?: boolean;
+ /** ChatView this kind belongs to; consumed by the generic `open` in step 6. */
+ view?: ChatView;
+};
+
+export type SlotKindRegistry = Partial<{
+ [TKind in ChatViewEntityKind]: SlotKindDefinition;
+}>;
+
+/** Minimal built-in renderer for the `userProfile` demo kind — proves the
+ * registry can render arbitrary UI in a slot, not just channel/thread/list. */
+const SlotUserProfile = ({ userId }: { userId: string }) => (
+
+ {userId}
+
+);
+
+export const defaultSlotKindRegistry: SlotKindRegistry = {
+ // `channel`/`thread` declare only policy; their renderer is app-supplied via
+ // the `slotRenderers` prop (folded in by `resolveSlotKindRegistry`).
+ channel: { view: 'channels' },
+ channelList: {
+ Component: () => ,
+ persistent: true,
+ },
+ threadList: {
+ Component: () => ,
+ persistent: true,
+ },
+ userProfile: {
+ Component: ({ source }) => ,
+ },
+};
+
+/** Fold the app's `slotRenderers` prop into a registry, overriding only each
+ * kind's `Component` while preserving the built-in policy (`persistent`/`view`). */
+export const resolveSlotKindRegistry = (
+ slotRenderers?: ChatViewSlotRenderers,
+ base: SlotKindRegistry = defaultSlotKindRegistry,
+): SlotKindRegistry => {
+ if (!slotRenderers) return base;
+ const merged: SlotKindRegistry = { ...base };
+ (Object.keys(slotRenderers) as ChatViewEntityKind[]).forEach((kind) => {
+ const Component = slotRenderers[kind];
+ if (!Component) return;
+ // Correlated-union assignment across a keyed record is not expressible to
+ // TS here; the key/value kinds are guaranteed to match by construction.
+ (merged as Record>)[kind] = {
+ ...(base[kind] as SlotKindDefinition | undefined),
+ Component: Component as SlotKindDefinition['Component'],
+ };
+ });
+ return merged;
+};
+
+/**
+ * State-aware dispatch for a slot's current binding. Looks up the bound kind in
+ * the registry and renders its pure `Component`; unknown/unregistered kinds and
+ * empty slots render nothing (the caller supplies a fallback).
+ */
+export const renderSlotFromRegistry = (
+ entity: ChatViewEntityBinding | undefined,
+ slot: string,
+ registry: SlotKindRegistry,
+): ReactNode | null => {
+ if (!entity) return null;
+ const definition = registry[entity.kind];
+ if (!definition?.Component) return null;
+ const Component = definition.Component as (props: {
+ entity: ChatViewEntityBinding;
+ slot: string;
+ source: ChatViewEntityBinding['source'];
+ }) => ReactNode;
+ return Component({ entity, slot, source: entity.source });
+};
+
+/** Per-kind policy readers used by the navigation layer (D6). */
+export const isPersistentSlotKind = (
+ registry: SlotKindRegistry,
+ kind?: ChatViewEntityKind,
+): boolean => !!kind && !!registry[kind]?.persistent;
+
+export const resolveSlotKindView = (
+ registry: SlotKindRegistry,
+ kind: ChatViewEntityKind,
+): ChatView | undefined => registry[kind]?.view;
+
+export const SlotRegistryContext = createContext(
+ defaultSlotKindRegistry,
+);
+
+export const useSlotRegistry = () => useContext(SlotRegistryContext);
diff --git a/src/components/ChatView/styling/ChatView.scss b/src/components/ChatView/styling/ChatView.scss
index 6504d042a3..ff7da458ec 100644
--- a/src/components/ChatView/styling/ChatView.scss
+++ b/src/components/ChatView/styling/ChatView.scss
@@ -95,6 +95,7 @@
display: flex;
flex-grow: 1;
min-width: 0;
+ min-height: 0;
position: relative;
> .str-chat__dropzone-root--thread,
@@ -106,3 +107,47 @@
}
}
}
+
+.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/Dialog/hooks/usePopoverPosition.ts b/src/components/Dialog/hooks/usePopoverPosition.ts
index 128ad33ae5..b3425f0f19 100644
--- a/src/components/Dialog/hooks/usePopoverPosition.ts
+++ b/src/components/Dialog/hooks/usePopoverPosition.ts
@@ -88,9 +88,21 @@ export function usePopoverPosition({
// viewport collision adjustments
...(allowShift ? [shiftMw(mergedShiftOptions)] : []),
- // optional size constraining
- // eslint-disable-next-line @typescript-eslint/no-empty-function
- ...(fitAvailableSpace ? [sizeMw({ apply: () => {} })] : []),
+ // optional size constraining: cap the floating element to the space left in the viewport
+ // (after flip/shift) so a tall popover scrolls inside the viewport instead of overflowing it.
+ ...(fitAvailableSpace
+ ? [
+ sizeMw({
+ apply({ availableHeight, availableWidth, elements }) {
+ Object.assign(elements.floating.style, {
+ maxHeight: `${Math.max(0, availableHeight)}px`,
+ maxWidth: `${Math.max(0, availableWidth)}px`,
+ });
+ },
+ padding: 8,
+ }),
+ ]
+ : []),
];
// if placement is 'auto*', seed with any static placement; autoPlacement will pick the final one
diff --git a/src/components/Form/Dropdown.tsx b/src/components/Form/Dropdown.tsx
index 326733e58c..97840d8c8d 100644
--- a/src/components/Form/Dropdown.tsx
+++ b/src/components/Form/Dropdown.tsx
@@ -52,6 +52,9 @@ export type DropdownTriggerProps = Pick<
export type DropdownProps = PropsWithChildren<{
className?: string;
+ /** Cap the menu to the space left in the viewport (after flip/shift) so it scrolls within
+ * the viewport instead of overflowing it. Combine with `overflow: auto` on the menu. */
+ fitAvailableSpace?: boolean;
matchReferenceWidth?: boolean;
onClose?: () => void;
onOpen?: () => void;
@@ -64,6 +67,7 @@ export type DropdownProps = PropsWithChildren<{
export const Dropdown = ({
children,
className,
+ fitAvailableSpace = false,
matchReferenceWidth = false,
onClose,
onOpen,
@@ -80,6 +84,7 @@ export const Dropdown = ({
const [isOpen, setIsOpen] = useState(!TriggerComponent);
const [floatingElement, setFloatingElement] = useState(null);
const { refs, strategy, update, x, y } = usePopoverPosition({
+ fitAvailableSpace,
placement,
});
diff --git a/src/components/Gallery/__tests__/Image.test.tsx b/src/components/Gallery/__tests__/Image.test.tsx
index 32c7e4f748..f3f120871d 100644
--- a/src/components/Gallery/__tests__/Image.test.tsx
+++ b/src/components/Gallery/__tests__/Image.test.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect } from 'react';
+import React from 'react';
import { act, cleanup, render, type RenderResult } from '@testing-library/react';
@@ -6,7 +6,7 @@ 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 { TranslationProvider } from '../../../context/TranslationContext';
import {
@@ -53,14 +53,6 @@ describe('Image', () => {
});
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,
@@ -73,8 +65,7 @@ describe('Image', () => {
result = render(
-
-
+
diff --git a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx
index 7457a1b69c..c803317f43 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,37 @@ export type InfiniteScrollPaginatorProps = React.ComponentProps<'div'> & {
useCapture?: boolean;
};
-export const InfiniteScrollPaginator = (
- props: PropsWithChildren,
-) => {
+// polymorphic props, defaulting to 'div'. `ref` is carried by `ComponentPropsWithRef`
+// (it is not omitted below), so the props type already exposes the correct ref — no separate
+// `ref?` signature is needed (and the react-compat lint rule forbids a literal one; the
+// component is a real `forwardRef` below).
+export type InfiniteScrollPaginatorProps =
+ PropsWithChildren<
+ InfiniteScrollPaginatorOwnProps & {
+ element?: C;
+ } & Omit<
+ React.ComponentPropsWithRef,
+ keyof InfiniteScrollPaginatorOwnProps | 'element'
+ >
+ >;
+
+type InfiniteScrollPaginatorComponent = (
+ props: InfiniteScrollPaginatorProps,
+) => React.ReactNode;
+
+const renderPolymorphic = (
+ Comp: C,
+ props: React.ComponentPropsWithRef,
+ 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 +70,7 @@ export const InfiniteScrollPaginator = (
...componentProps
} = props;
- const rootRef = useRef(null);
+ const rootRef = useRef(null);
const childRef = useRef(null);
const scrollListener = useMemo(
@@ -114,15 +141,24 @@ export const InfiniteScrollPaginator = (
};
}, [useCapture]);
- return (
-
+ 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/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx
new file mode 100644
index 0000000000..3c93c3e4d1
--- /dev/null
+++ b/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx
@@ -0,0 +1,76 @@
+import type { ComponentType } from 'react';
+import React from 'react';
+import type { PaginatorState, StateStore } from 'stream-chat';
+
+import { useStateStore } from '../../store';
+import type { InfiniteScrollPaginatorProps } from './InfiniteScrollPaginator';
+import { InfiniteScrollPaginator } from './InfiniteScrollPaginator';
+
+/** Minimal structural view of a paginator this component reads — avoids coupling
+ * to `BasePaginator`'s generic variance (items only need an optional `id` for keys). */
+type PaginatorLike = {
+ hasNext: boolean;
+ hasPrev: boolean;
+ state: StateStore>;
+};
+
+export type InfiniteScrollWithComponentsProps =
+ InfiniteScrollPaginatorProps & {
+ EmptyListIndicator: React.ComponentType;
+ EndReachedIndicator: React.ComponentType<{
+ hasEnded: boolean;
+ reached: 'top' | 'bottom';
+ }>;
+ FirstPageLoadingIndicator: React.ComponentType;
+ ListItem: ComponentType<{ item: TItem }>;
+ LoadingNextPageIndicator: React.ComponentType<{ isLoading?: boolean }>;
+ paginator: PaginatorLike;
+ hasReachedBottom?: (paginator: PaginatorLike) => boolean;
+ hasReachedTop?: (paginator: PaginatorLike) => boolean;
+ };
+
+/**
+ * Renders any paginator-backed list with pluggable indicator/item components,
+ * driven by the paginator's reactive `state`. Used by the orchestrator-driven
+ * channel list.
+ */
+export const InfiniteScrollWithComponents = (
+ props: InfiniteScrollWithComponentsProps,
+) => {
+ const {
+ EmptyListIndicator,
+ EndReachedIndicator,
+ FirstPageLoadingIndicator,
+ hasReachedBottom,
+ hasReachedTop,
+ ListItem,
+ LoadingNextPageIndicator,
+ paginator,
+ ...componentProps
+ } = props;
+
+ const { isLoading, items } = useStateStore(paginator.state, (state) => ({
+ isLoading: state.isLoading,
+ items: state.items,
+ }));
+ const isLoadingFirstPage = items === undefined;
+ const isEmpty = items?.length === 0;
+ const topEndReached = hasReachedTop?.(paginator) ?? !paginator.hasPrev;
+ const bottomEndReached = hasReachedBottom?.(paginator) ?? !paginator.hasNext;
+
+ if (isLoadingFirstPage) return ;
+ if (isEmpty) return ;
+
+ return (
+ <>
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+
+ >
+ );
+};
diff --git a/src/components/MediaRecorder/hooks/useMediaRecorder.ts b/src/components/MediaRecorder/hooks/useMediaRecorder.ts
index 82c543b102..b59a420fc7 100644
--- a/src/components/MediaRecorder/hooks/useMediaRecorder.ts
+++ b/src/components/MediaRecorder/hooks/useMediaRecorder.ts
@@ -6,6 +6,7 @@ import { useMessageComposerController } from '../../MessageComposer/hooks/useMes
import type { LocalVoiceRecordingAttachment } from 'stream-chat';
import type { CustomAudioRecordingConfig, MediaRecordingState } from '../classes';
import type { MessageComposerContextValue } from '../../../context';
+import { useSendMessageFn } from '../../MessageComposer/hooks/useSendMessageFn';
export type RecordingController = {
completeRecording: () => void;
@@ -17,7 +18,7 @@ export type RecordingController = {
type UseMediaRecorderParams = Pick<
MessageComposerContextValue,
- '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 = useMessageComposerController();
+ 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 49cebdb5d9..f359122ab1 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,
@@ -17,24 +17,22 @@ import {
} from './hooks';
import { areMessagePropsEqual, getMessageActions, MESSAGE_ACTIONS } from './utils';
+import type { LocalMessage } from 'stream-chat';
import type { MessageContextValue } from '../../context';
import {
MessageProvider,
- useChannelStateContext,
+ useChannel,
useChatContext,
useComponentContext,
useMessageTranslationViewContext,
} from '../../context';
+import { useChannelConfig } from '../Channel/hooks/useChannelConfig';
import { MessageUI as DefaultMessageUI } from './MessageUI';
import type { MessageProps } from './types';
-type MessagePropsToOmit =
- | 'onMentionsClick'
- | 'onMentionsHover'
- | 'openThread'
- | 'retrySendMessage';
+type MessagePropsToOmit = 'onMentionsClick' | 'onMentionsHover' | 'retrySendMessage';
type MessageContextPropsToPick =
| 'handleAction'
@@ -47,7 +45,6 @@ type MessageContextPropsToPick =
| 'handlePin'
| 'handleReaction'
| 'handleRetry'
- | 'mutes'
| 'onMentionsClickMessage'
| 'onMentionsHoverMessage'
| 'reactionDetailsSort'
@@ -55,13 +52,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),
@@ -70,8 +65,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 = DefaultMessageUI,
// TODO: remove this passthrough once we drop Message from the ComponentContext
@@ -100,26 +96,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(
@@ -154,7 +137,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
onUserClick: onUserClickPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars
onUserHover: onUserHoverPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars
@@ -168,7 +150,6 @@ const MessageWithContext = (props: MessageWithContextProps) => {
getMessageActions: messageActionsHandler,
isMessageAIGenerated,
isMyMessage: () => isMyMessage,
- messageIsUnread,
onUserClick,
onUserHover,
setTranslationView,
@@ -200,16 +181,17 @@ export const Message = (props: MessageProps) => {
onMentionsHover: propOnMentionsHover,
openThread: propOpenThread,
reactionDetailsSort,
- retrySendMessage: propRetrySendMessage,
sortReactions,
} = props;
- const { highlightedMessageId, mutes } = useChannelStateContext('Message');
-
+ // MERGE-RECONCILE: master removed PR #2909's per-action notification-getter props
+ // (getDeleteMessageErrorNotification, getFetchReactionsErrorNotification, etc.) and the
+ // `notify` bridge; the merged handler hooks emit errors via client.notifications
+ // internally. Handlers are called with `(message)` only (master's canonical style).
+ // The per-action custom error/success message API is a dropped PR feature — re-graft if
+ // custom notification text is required.
const handleAction = useActionHandler(message);
- const handleOpenThread = useOpenThreadHandler(message, propOpenThread);
const handleReaction = useReactionHandler(message);
- const handleRetry = useRetryHandler(propRetrySendMessage);
const userRoles = useUserRole(message, disableQuotedMessages);
const handleFetchReactions = useReactionsFetcher(message);
@@ -227,15 +209,18 @@ export const Message = (props: MessageProps) => {
onMentionsHover: propOnMentionsHover,
});
- const { canPin, handlePin } = usePinHandler(message);
-
- const highlighted = highlightedMessageId === message.id;
+ const { handlePin } = usePinHandler(message);
+ const handleOpenThread = useOpenThreadHandler(message, propOpenThread);
+ const retryHandler = useRetryHandler();
+ const handleRetry = useCallback(
+ (retriedMessage: LocalMessage) => retryHandler({ localMessage: retriedMessage }),
+ [retryHandler],
+ );
return (
{
handlePin={handlePin}
handleReaction={handleReaction}
handleRetry={handleRetry}
- highlighted={highlighted}
+ highlighted={props.highlighted}
initialMessage={props.initialMessage}
lastOwnMessage={props.lastOwnMessage}
lastReceivedId={props.lastReceivedId}
@@ -259,7 +244,6 @@ export const Message = (props: MessageProps) => {
Message={props.Message}
messageActions={props.messageActions}
messageListRect={props.messageListRect}
- mutes={mutes}
onMentionsClickMessage={onMentionsClick}
onMentionsHoverMessage={onMentionsHover}
onUserClick={props.onUserClick}
diff --git a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx
index 29d9fe48c6..c95eb08cbb 100644
--- a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx
+++ b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx
@@ -1,97 +1,40 @@
-import React, { useEffect, useRef } from 'react';
+import React from 'react';
import { IconArrowUpRight } from '../Icons';
-import { useNotificationApi } from '../Notifications';
-import {
- useChannelActionContext,
- useChannelStateContext,
- useMessageContext,
- useTranslationContext,
-} from '../../context';
-import { formatMessage, type LocalMessage } from 'stream-chat';
+import { useTranslationContext } from '../../context';
+import { useMessageAlsoSentInChannelNavigation } from './hooks';
+
+export type MessageAlsoSentInChannelIndicatorProps = {
+ /**
+ * Overrides the "View" click handler. Defaults to the built-in reference navigation
+ * (`useMessageAlsoSentInChannelNavigation().viewReference`). Integrators can supply their own —
+ * e.g. to run extra layout side effects around the jump — by composing that hook, without
+ * re-implementing the component. Returning a promise is supported.
+ */
+ onView?: () => void | Promise;
+};
/**
* Indicator shown when the message was also sent to the main channel (show_in_channel === true).
+ * The navigation lives in {@link useMessageAlsoSentInChannelNavigation} so it can be reused.
*/
-export const MessageAlsoSentInChannelIndicator = () => {
- const { addNotification } = useNotificationApi();
+export const MessageAlsoSentInChannelIndicator = ({
+ onView,
+}: MessageAlsoSentInChannelIndicatorProps = {}) => {
const { t } = useTranslationContext();
- const { channel } = useChannelStateContext();
- const { jumpToMessage, openThread } = useChannelActionContext();
- const { message, threadList } = useMessageContext('MessageAlsoSentInChannelIndicator');
- const targetMessageRef = useRef(undefined);
-
- 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) => {
- addNotification({
- context: { threadReply: message },
- emitter: 'MessageIsThreadReplyInChannelButtonIndicator',
- error,
- message: t('Thread has not been found'),
- severity: 'error',
- type: 'api:reply:search:failed',
- });
- });
-
- // 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
- };
-
- 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 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
- 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;
- return;
- }
-
- if (threadList) await jumpToReplyInChannelMessages(message.id);
- else openThread(target);
- };
+ const { isInThread, isShownInChannel, viewReference } =
+ useMessageAlsoSentInChannelNavigation();
- if (!message?.show_in_channel) return null;
+ if (!isShownInChannel) return null;
return (
-
{threadList ? t('Also sent in channel') : t('Replied to a thread')}
+
{isInThread ? t('Also sent in channel') : t('Replied to a thread')}
·
-
-
@@ -492,15 +350,22 @@ type PropsDrilledToMessage =
| 'onMentionsHover'
| 'onUserClick'
| 'onUserHover'
- | 'openThread'
| 'reactionDetailsSort'
| 'renderText'
- | 'retrySendMessage'
+ // | 'retrySendMessage'
| 'showAvatar'
| 'sortReactions'
| 'unsafeHTML';
+// Allow intrinsic element override while keeping div-prop compatibility
+type InternalPaginatorProps = Partial<
+ Omit, 'element'>
+> & {
+ element?: keyof React.JSX.IntrinsicElements;
+};
+
export type MessageListProps = Partial> & {
+ // todo: data manipulation - should live in the paginator
/** Disables the injection of date separator components in MessageList, defaults to `false` */
disableDateSeparator?: boolean;
/** Callback function to set group styles for each message */
@@ -517,29 +382,29 @@ export type MessageListProps = Partial
head?: React.ReactElement;
/** Position to render HeaderComponent */
headerPosition?: number;
+ // todo: data manipulation - should live in MessagePaginator
/** Hides the MessageDeleted components from the list, defaults to `false` */
hideDeletedMessages?: boolean;
+ // todo: data manipulation - should live in MessagePaginator
/** Hides the DateSeparator component when new messages are received in a channel that's watched but not active, defaults to false */
hideNewMessageSeparator?: boolean;
- /** Overrides the default props passed to [InfiniteScroll](https://github.com/GetStream/stream-chat-react/blob/master/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx) */
- internalInfiniteScrollProps?: Partial;
+ /** Overrides the default props passed to [InfiniteScrollPaginator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx) */
+ internalInfiniteScrollProps?: InternalPaginatorProps;
/** Function called when latest messages should be loaded, after the list has jumped at an earlier message set */
jumpToLatestMessage?: () => Promise;
/** Whether or not the list is currently loading more items */
loadingMore?: boolean;
- /** Whether or not the list is currently jumping to a highlighted message */
- loadingMoreForJumpToChannelMessage?: 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 newer messages are to be loaded, defaults to function stored in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */
- loadMoreNewer?: ChannelActionContextValue['loadMoreNewer'] | (() => Promise);
+ /** Function called when more messages are to be loaded. */
+ // loadMore?: () => Promise;
+ /** Function called when newer messages are to be loaded. */
+ // loadMoreNewer?: () => Promise;
/** Maximum time in milliseconds that should occur between messages to still consider them grouped together */
maxTimeBetweenGroupedMessages?: number;
/** The limit to use when paginating messages */
messageLimit?: number;
- /** The messages to render in the list, defaults to messages stored in [ChannelStateContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_state_context/) */
+ /** The messages to render in the list; defaults to the active message-paginator items. */
messages?: LocalMessage[];
/** If true, turns off message UI grouping by user */
noGroupByUser?: boolean;
@@ -551,6 +416,7 @@ export type MessageListProps = Partial
* Allows to review changes introduced to messages array on per message basis (e.g. date separator injection before a message).
* The array returned from the function is appended to the array of messages that are later rendered into React elements in the `MessageList`.
*/
+ // todo: have state.pipe() API to allow modifying the state output / emission
reviewProcessedMessage?: ProcessMessagesParams['reviewProcessedMessage'];
/**
* The pixel threshold under which the message list is considered to be so near to the bottom,
@@ -564,38 +430,19 @@ export type MessageListProps = Partial
* is shown only when viewing unread messages.
*/
showUnreadNotificationAlways?: boolean;
- /** If true, indicates the message list is a thread */
- threadList?: boolean; // todo: refactor needed - message list should have a state in which among others it would be optionally flagged as thread
+ /** If true, prevents autoscroll-to-bottom behavior on new messages. */
+ suppressAutoscroll?: boolean;
};
/**
* The MessageList component renders a list of Messages.
* It is a consumer of 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/)
+ * - `ChannelInstanceContext`
* - [ComponentContext](https://getstream.io/chat/docs/sdk/react/contexts/component_context/)
* - [TypingContext](https://getstream.io/chat/docs/sdk/react/contexts/typing_context/)
*/
-export const MessageList = (props: MessageListProps) => {
- const { jumpToLatestMessage, loadMore, loadMoreNewer } =
- useChannelActionContext('MessageList');
-
- const {
- members: membersPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars
- mutes: mutesPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars
- watchers: watchersPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars
- ...restChannelStateContext
- } = useChannelStateContext('MessageList');
-
- return (
-
-
-
- );
-};
+export const MessageList = (props: MessageListProps) => (
+
+
+
+);
diff --git a/src/components/MessageList/ScrollToLatestMessageButton.tsx b/src/components/MessageList/ScrollToLatestMessageButton.tsx
index 99d7c5de3b..453aed293f 100644
--- a/src/components/MessageList/ScrollToLatestMessageButton.tsx
+++ b/src/components/MessageList/ScrollToLatestMessageButton.tsx
@@ -1,12 +1,10 @@
import React, { useEffect, useState } from 'react';
-import {
- useChannelStateContext,
- useChatContext,
- useTranslationContext,
-} from '../../context';
+import { useChannel, useChatContext, useTranslationContext } from '../../context';
+import { useThreadContext } from '../Threads/ThreadContext';
+import { useStateStore } from '../../store';
-import type { Event } from 'stream-chat';
+import type { Event, ThreadState } from 'stream-chat';
import { Badge } from '../Badge';
import { Button } from '../Button';
import { IconArrowDown } from '../Icons';
@@ -16,9 +14,12 @@ export type ScrollToLatestMessageButtonProps = {
isNotAtLatestMessageSet?: boolean;
isMessageListScrolledToBottom?: boolean;
onClick: React.MouseEventHandler;
- threadList?: boolean;
};
+const threadStateSelector = ({ parentMessage }: ThreadState) => ({
+ parentMessage,
+});
+
const UnMemoizedScrollToLatestMessageButton = (
props: ScrollToLatestMessageButtonProps,
) => {
@@ -26,25 +27,26 @@ const UnMemoizedScrollToLatestMessageButton = (
isMessageListScrolledToBottom,
isNotAtLatestMessageSet = false,
onClick,
- threadList,
} = props;
- const { channel: activeChannel, client } = useChatContext();
- const { thread } = useChannelStateContext();
+ const { client } = useChatContext();
+ const channel = useChannel();
+ const thread = useThreadContext();
+ const isThreadList = !!thread;
+ const { parentMessage } = useStateStore(thread?.state, threadStateSelector) ?? {};
const { t } = useTranslationContext();
- const [countUnread, setCountUnread] = useState(activeChannel?.countUnread() || 0);
- const [replyCount, setReplyCount] = useState(thread?.reply_count || 0);
- const observedEvent = threadList ? 'message.updated' : 'message.new';
+ const [countUnread, setCountUnread] = useState(channel.countUnread() || 0);
+ const [replyCount, setReplyCount] = useState(parentMessage?.reply_count || 0);
+ const observedEvent = isThreadList ? 'message.updated' : 'message.new';
useEffect(() => {
const handleEvent = (event: Event) => {
- const newMessageInAnotherChannel = event.cid !== activeChannel?.cid;
+ const newMessageInAnotherChannel = event.cid !== channel?.cid;
const newMessageIsMine = event.user?.id === client.user?.id;
- const isThreadOpen = !!thread;
const newMessageIsReply = !!event.message?.parent_id;
const dontIncreaseMainListCounterOnNewReply =
- isThreadOpen && !threadList && newMessageIsReply;
+ channel && !isThreadList && newMessageIsReply;
if (
isMessageListScrolledToBottom ||
@@ -58,7 +60,7 @@ const UnMemoizedScrollToLatestMessageButton = (
if (event.type === 'message.new') {
// cannot rely on channel.countUnread because active channel is automatically marked read
setCountUnread((prev) => prev + 1);
- } else if (event.message?.id === thread?.id) {
+ } else if (event.message?.id === parentMessage?.id) {
const newReplyCount = event.message?.reply_count || 0;
setCountUnread(() => newReplyCount - replyCount);
}
@@ -69,21 +71,21 @@ const UnMemoizedScrollToLatestMessageButton = (
client.off(observedEvent, handleEvent);
};
}, [
- activeChannel,
+ channel,
client,
isMessageListScrolledToBottom,
observedEvent,
+ parentMessage,
replyCount,
- thread,
- threadList,
+ isThreadList,
]);
useEffect(() => {
if (isMessageListScrolledToBottom) {
setCountUnread(0);
- setReplyCount(thread?.reply_count || 0);
+ setReplyCount(parentMessage?.reply_count || 0);
}
- }, [isMessageListScrolledToBottom, thread]);
+ }, [isMessageListScrolledToBottom, parentMessage]);
if (isMessageListScrolledToBottom && !isNotAtLatestMessageSet) return null;
diff --git a/src/components/MessageList/UnreadMessagesNotification.tsx b/src/components/MessageList/UnreadMessagesNotification.tsx
index a0a23aaa27..0c88f0cdfb 100644
--- a/src/components/MessageList/UnreadMessagesNotification.tsx
+++ b/src/components/MessageList/UnreadMessagesNotification.tsx
@@ -1,8 +1,12 @@
import React from 'react';
-import { useChannelActionContext, useTranslationContext } from '../../context';
+import { useChannel, useChatContext, useTranslationContext } from '../../context';
+import { useMessagePaginator } from '../../hooks';
import { Button } from '../Button';
import { IconArrowUp, IconXmark } from '../Icons';
import clsx from 'clsx';
+import { useThreadContext } from '../Threads';
+import type { UnreadSnapshotState } from 'stream-chat';
+import { useStateStore } from '../../store';
export type UnreadMessagesNotificationProps = {
/**
@@ -13,18 +17,28 @@ export type UnreadMessagesNotificationProps = {
* Configuration parameter to determine, whether the unread count is to be shown on the component. Enabled by default.
*/
showCount?: boolean;
- /**
- * The count of unread messages to be displayed if enabled.
- */
+ // todo: maybe remove?
unreadCount?: number;
};
+const unreadStateSnapshotSelector = (state: UnreadSnapshotState) => ({
+ unreadCount: state.unreadCount,
+});
+
export const UnreadMessagesNotification = ({
queryMessageLimit,
showCount = true,
- unreadCount,
}: UnreadMessagesNotificationProps) => {
- const { jumpToFirstUnreadMessage, markRead } = useChannelActionContext();
+ // todo: move into a hook dedicated to unread count from the snapshot
+ const channel = useChannel();
+ const { client } = useChatContext('UnreadMessagesNotification');
+ const thread = useThreadContext();
+ const messagePaginator = useMessagePaginator();
+ const { unreadCount } = useStateStore(
+ messagePaginator.unreadStateSnapshot,
+ unreadStateSnapshotSelector,
+ );
+
const { t } = useTranslationContext('UnreadMessagesNotification');
return (
@@ -36,7 +50,11 @@ export const UnreadMessagesNotification = ({
>
jumpToFirstUnreadMessage(queryMessageLimit)}
+ onClick={() =>
+ messagePaginator.jumpToTheFirstUnreadMessage({
+ pageSize: queryMessageLimit,
+ })
+ }
variant='secondary'
>
@@ -47,7 +65,13 @@ export const UnreadMessagesNotification = ({
markRead()}
+ onClick={() => {
+ if (thread) {
+ client.messageDeliveryReporter.throttledMarkRead(thread);
+ return;
+ }
+ client.messageDeliveryReporter.throttledMarkRead(channel);
+ }}
variant='secondary'
>
diff --git a/src/components/MessageList/UnreadMessagesSeparator.tsx b/src/components/MessageList/UnreadMessagesSeparator.tsx
index 884b8594be..c565b61bb1 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 { IconXmark } 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 30cae0d546..fe0fa0603b 100644
--- a/src/components/MessageList/VirtualizedMessageList.tsx
+++ b/src/components/MessageList/VirtualizedMessageList.tsx
@@ -23,6 +23,12 @@ import {
useUnreadMessagesNotificationVirtualized,
} from './hooks/VirtualizedMessageList';
import { useMarkRead } from './hooks/useMarkRead';
+
+import {
+ NotificationList as DefaultNotificationList,
+ useNotificationTarget,
+} from '../Notifications';
+import { AriaLiveRegion, useIncomingMessageAnnouncements } from '../Accessibility';
import { NewMessageNotification as DefaultNewMessageNotification } from './NewMessageNotification';
import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel';
import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils';
@@ -41,50 +47,41 @@ import {
} from './VirtualizedMessageListComponents';
import {
- ScrollToLatestMessageButton as DefaultScrollToLatestMessageButton,
UnreadMessagesSeparator as DefaultUnreadMessagesSeparator,
+ ScrollToLatestMessageButton,
} from '../MessageList';
import { DateSeparator as DefaultDateSeparator } from '../DateSeparator';
import { EventComponent as DefaultMessageSystem } from '../EventComponent';
-import {
- NotificationList as DefaultNotificationList,
- useNotificationTarget,
-} from '../Notifications';
-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';
-import { useReducedMotionPreference } from './hooks/useReducedMotionPreference';
-import { AriaLiveRegion, useIncomingMessageAnnouncements } from '../Accessibility';
type PropsDrilledToMessage =
| 'additionalMessageComposerProps'
| 'formatDate'
| 'messageActions'
- | 'openThread'
| 'reactionDetailsSort'
| 'renderText'
| 'showAvatar'
@@ -95,11 +92,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)
@@ -112,8 +108,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. */
@@ -129,29 +127,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'];
- thread?: ChannelStateContextValue['thread'];
};
+const channelReadSelector = (nextValue: { read: StreamChannelState['read'] }) => ({
+ read: nextValue.read,
+});
+
function captureResizeObserverExceededError(e: ErrorEvent) {
if (
e.message === 'ResizeObserver loop completed with undelivered notifications.' ||
@@ -180,10 +182,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;
}
@@ -191,6 +193,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,
) => {
@@ -198,28 +211,26 @@ const VirtualizedMessageListWithContext = (
additionalMessageComposerProps,
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,
- openThread,
+ // messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE,
+ // messages,
// TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component
overscan = 0,
reactionDetailsSort,
@@ -234,10 +245,15 @@ const VirtualizedMessageListWithContext = (
showUnreadNotificationAlways,
sortReactions,
stickToBottomScrollBehavior = 'smooth',
- suppressAutoscroll,
- thread,
- 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;
@@ -253,7 +269,6 @@ const VirtualizedMessageListWithContext = (
MessageSystem = DefaultMessageSystem,
NewMessageNotification = DefaultNewMessageNotification,
NotificationList = DefaultNotificationList,
- ScrollToLatestMessageButton = DefaultScrollToLatestMessageButton,
TypingIndicator,
UnreadMessagesNotification = DefaultUnreadMessagesNotification,
UnreadMessagesSeparator = DefaultUnreadMessagesSeparator,
@@ -262,11 +277,22 @@ const VirtualizedMessageListWithContext = (
const MessageUIComponent = MessageUIComponentFromProps || MessageUIComponentFromContext;
const { client, customClasses } = useChatContext('VirtualizedMessageList');
- const prefersReducedMotion = useReducedMotionPreference();
- const effectiveStickToBottomScrollBehavior = prefersReducedMotion
- ? 'auto'
- : stickToBottomScrollBehavior;
- const notificationTarget = useNotificationTarget();
+ 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);
@@ -274,9 +300,7 @@ const VirtualizedMessageListWithContext = (
const { show: showUnreadMessagesNotification, toggleShowUnreadMessagesNotification } =
useUnreadMessagesNotificationVirtualized({
- lastRead: channelUnreadUiState?.last_read,
showAlways: !!showUnreadNotificationAlways,
- unreadCount: channelUnreadUiState?.unread_messages ?? 0,
});
const { giphyPreviewMessage, setGiphyPreviewMessage } =
@@ -379,34 +403,39 @@ 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;
- }
+ const notificationTarget = useNotificationTarget();
- if (virtuoso.current) {
+ useIncomingMessageAnnouncements({
+ activeThreadId: thread?.id,
+ channel,
+ ownUserId: channel.getClient().user?.id,
+ threadList: isThreadList,
+ });
+
+ const scrollToBottom = useCallback(async () => {
+ 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({
@@ -427,13 +456,6 @@ const VirtualizedMessageListWithContext = (
client.userID,
);
- useIncomingMessageAnnouncements({
- activeThreadId: thread?.id,
- channel,
- ownUserId: client.userID,
- threadList,
- });
-
const handleItemsRendered = useMemo(
() =>
makeItemsRenderedHandler(
@@ -446,15 +468,15 @@ const VirtualizedMessageListWithContext = (
[processedMessages, toggleShowUnreadMessagesNotification],
);
const followOutput = (isAtBottom: boolean) => {
- if (hasMoreNewer || suppressAutoscroll) {
+ if (messagePaginator.hasMoreHead || suppressAutoscroll) {
return false;
}
if (shouldForceScrollToBottom()) {
- return isAtBottom ? effectiveStickToBottomScrollBehavior : 'auto';
+ return isAtBottom ? stickToBottomScrollBehavior : 'auto';
}
// a message from another user has been received - don't scroll to bottom unless already there
- return isAtBottom ? effectiveStickToBottomScrollBehavior : false;
+ return isAtBottom ? stickToBottomScrollBehavior : false;
};
const computeItemKey = useCallback>(
@@ -468,20 +490,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 });
@@ -491,13 +520,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}`;
@@ -506,9 +535,9 @@ const VirtualizedMessageListWithContext = (
- {!threadList && showUnreadMessagesNotification && (
+ {!isThreadList && showUnreadMessagesNotification && (
)}
- isMessageListScrolledToBottom ? (
-
- ) : null,
- }),
...virtuosoComponentsFromProps,
}}
computeItemKey={computeItemKey}
context={{
additionalMessageComposerProps,
+ channel,
closeReactionSelectorOnClick,
customClasses,
customMessageRenderer,
DateSeparator,
- firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id,
+ firstUnreadMessageId: channelUnreadUiState?.firstUnreadMessageId,
+ focusedMessageId,
formatDate,
head,
lastOwnMessage,
- lastReadDate: channelUnreadUiState?.last_read,
- lastReadMessageId: channelUnreadUiState?.last_read_message_id,
+ lastReadDate: channelUnreadUiState?.lastReadAt,
+ lastReadMessageId: channelUnreadUiState?.lastReadMessageId,
lastReceivedMessageId,
- loadingMore,
+ loadingMore: isLoading,
Message: MessageUIComponent,
messageActions,
messageGroupStyles,
MessageSystem,
numItemsPrepended,
- openThread,
ownMessagesDeliveredToOthers,
ownMessagesReadByOthers,
processedMessages,
@@ -573,8 +593,7 @@ const VirtualizedMessageListWithContext = (
shouldGroupByUser,
showAvatar,
sortReactions,
- threadList,
- unreadMessageCount: channelUnreadUiState?.unread_messages,
+ unreadMessageCount: channelUnreadUiState?.unreadCount,
UnreadMessagesSeparator,
virtuosoRef: virtuoso,
}}
@@ -583,7 +602,7 @@ const VirtualizedMessageListWithContext = (
increaseViewportBy={{ bottom: 200, top: 0 }}
initialTopMostItemIndex={calculateInitialTopMostItemIndex(
processedMessages,
- highlightedMessageId,
+ focusedMessageId,
)}
itemContent={messageRenderer}
itemSize={fractionalItemSize}
@@ -598,17 +617,20 @@ const VirtualizedMessageListWithContext = (
{...(defaultItemHeight ? { defaultItemHeight } : {})}
/>
0}
onClick={scrollToBottom}
- threadList={threadList}
/>
+ {TypingIndicator && }
{giphyPreviewMessage && }
@@ -622,7 +644,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 */
@@ -644,10 +665,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
@@ -657,23 +678,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 [MessageUI](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageUI.tsx) */
+ /** 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`.
@@ -705,7 +724,7 @@ export type VirtualizedMessageListProps = Partial<
};
/** When `true`, the list will scroll to the latest message when the window regains focus */
scrollToLatestMessageOnFocus?: boolean;
- /** If true, the Giphy preview will render as a separate component above the `MessageComposer`, rather than inline with the other messages in the list */
+ /** If true, the Giphy preview will render as a separate component above the `MessageInput`, rather than inline with the other messages in the list */
separateGiphyPreview?: boolean;
/** If true, group messages belonging to the same user, otherwise show each message individually */
shouldGroupByUser?: boolean;
@@ -715,12 +734,10 @@ export type VirtualizedMessageListProps = Partial<
* is shown only when viewing unread messages.
*/
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. Reduced-motion users are always treated as `"auto"`. */
+ /** 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;
};
/**
@@ -728,44 +745,26 @@ 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,
- thread,
- } = 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 0e273dac8f..4d01c79d76 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 {
additionalMessageComposerProps,
+ channel,
closeReactionSelectorOnClick,
customMessageRenderer,
DateSeparator,
firstUnreadMessageId,
+ focusedMessageId,
formatDate,
lastOwnMessage,
lastReadDate,
@@ -122,7 +127,6 @@ export const messageRenderer = (
messageGroupStyles,
MessageSystem,
numItemsPrepended,
- openThread,
ownMessagesDeliveredToOthers,
ownMessagesReadByOthers,
processedMessages: messageList,
@@ -131,7 +135,6 @@ export const messageRenderer = (
returnAllReadData,
showAvatar,
sortReactions,
- threadList,
unreadMessageCount = 0,
UnreadMessagesSeparator,
virtuosoRef,
@@ -158,13 +161,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 (
@@ -181,19 +185,18 @@ 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}
returnAllReadData={returnAllReadData}
showAvatar={showAvatar}
sortReactions={sortReactions}
- threadList={threadList}
/>
>
);
diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.tsx.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.tsx.snap
index c0634efb4b..67dc80816a 100644
--- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.tsx.snap
+++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.tsx.snap
@@ -22,7 +22,7 @@ exports[`VirtualizedMessageList > should render the list without any message 1`]
id="str-chat__channel"
>
{
- listElement.removeEventListener('scroll', throttled);
- throttled.cancel();
- };
- }
-
- 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 236ddc837e..ee053e0205 100644
--- a/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts
+++ b/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts
@@ -23,6 +23,7 @@ export type UseMessageListScrollManagerParams = {
scrolledUpThreshold: number;
scrollToBottom: () => void;
showNewMessages: () => void;
+ suppressAutoscroll?: boolean;
};
// Tracks how the current older-page pagination cycle should restore the viewport
@@ -95,6 +96,7 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP
scrolledUpThreshold,
scrollToBottom,
showNewMessages,
+ suppressAutoscroll = false,
} = params;
const { client } = useChatContext('useMessageListScrollManager');
@@ -234,7 +236,7 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP
const lastMessageIsFromCurrentUser = lastNewMessage.user?.id === client.userID;
- if (lastMessageIsFromCurrentUser || wasAtBottom) {
+ if (!suppressAutoscroll && (lastMessageIsFromCurrentUser || wasAtBottom)) {
scrollToBottom();
} else {
showNewMessages();
@@ -273,6 +275,10 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP
messages.current = newMessages;
measures.current = newMeasures;
previousLoadingMoreRef.current = loadingMore;
+ // MERGE-RECONCILE: deps are the UNION of master's anchor-based scroll params and
+ // PR #2909's suppressAutoscroll (exhaustive-deps is disabled, so extra deps are safe).
+ // The scroll logic here (master) and useScrollLocationLogic (PR base) diverged;
+ // verify the two scroll paths cooperate rather than double-manage scrolling.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
captureAnchor,
@@ -284,6 +290,7 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP
params.messages,
restoreAnchor,
scrollContainerMeasures,
+ suppressAutoscroll,
]);
return (
diff --git a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx
index 8e840eec02..fd2495aeed 100644
--- a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx
+++ b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx
@@ -5,430 +5,112 @@ import { useMessageListScrollManager } from './useMessageListScrollManager';
import type { LocalMessage } from 'stream-chat';
export type UseScrollLocationLogicParams = {
- /** Disables automatic scroll-to-bottom updates after message changes. */
- disableAutoScrollToBottom?: boolean;
- /** Disables scroll-management adjustments (anchor restore, append/prepend handling). */
- disableScrollManagement?: boolean;
- /** True when there are newer messages to load beyond the currently rendered page. */
hasMoreNewer: boolean;
- /** Scrollable message-list container element. */
- listElement: HTMLDivElement | null;
- /** Threshold used to detect older-page pagination proximity near the top. */
+ listElement: HTMLElement | null;
loadMoreScrollThreshold: number;
- /** Indicates whether older-page pagination is currently in progress. */
- loadingMore?: boolean;
- /** Hard-disable all autoscroll behavior. */
- suppressAutoscroll: boolean;
- /** Current rendered message set used for scroll reconciliation. */
+ suppressAutoscroll?: boolean;
messages?: LocalMessage[];
- /** Distance from bottom (px) considered as "near bottom". */
scrolledUpThreshold?: number;
};
-/**
- * Centralized scroll-position logic for MessageList.
- *
- * Responsibilities:
- * - Keep viewport stable during prepend/append pagination updates.
- * - Track whether the list is near bottom and expose that state to UI.
- * - Auto-scroll to bottom when appropriate while respecting suppression flags.
- * - Perform a short hydration settle pass so freshly loaded lists land at bottom.
- */
export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => {
const {
- disableAutoScrollToBottom = false,
- disableScrollManagement = false,
hasMoreNewer,
listElement,
- loadingMore = false,
loadMoreScrollThreshold,
messages = [],
scrolledUpThreshold = 200,
- suppressAutoscroll,
+ suppressAutoscroll = false,
} = params;
const [hasNewMessages, setHasNewMessages] = useState(false);
const [wrapperRect, setWrapperRect] = useState();
- const previousHasMoreNewerRef = useRef(hasMoreNewer);
- const justReachedLatestMessageSet = previousHasMoreNewerRef.current && !hasMoreNewer;
- const skipNextAutoScrollRef = useRef(false);
- const isRestoringOlderAnchorRef = useRef(false);
const [isMessageListScrolledToBottom, setIsMessageListScrolledToBottom] =
useState(true);
const closeToBottom = useRef(false);
const closeToTop = useRef(false);
- const previousScrollTopRef = useRef(0);
- const previousMessagesLengthRef = useRef(messages.length);
- const previousDisableAutoScrollToBottomRef = useRef(disableAutoScrollToBottom);
- const previousDisableAutoScrollSettleRef = useRef(disableAutoScrollToBottom);
- const anchorRestoreCleanupRef = useRef<(() => void) | null>(null);
+ const initialDataAutoscrollDoneRef = useRef(false);
- const captureAnchor = useCallback(() => {
- if (!listElement) return null;
+ const scrollToBottom = useCallback(() => {
+ if (!listElement?.scrollTo || hasMoreNewer || suppressAutoscroll) {
+ return;
+ }
- const listRect = listElement.getBoundingClientRect();
- const listTop = listRect.top;
- const listBottom = listRect.bottom;
- const listCenter = listTop + listRect.height / 2;
- // Older-page pagination should track the top edge, while the generic
- // “keep this viewport stable” case works better from the center.
- const preferTopEdgeAnchor =
- loadingMore || listElement.scrollTop < loadMoreScrollThreshold;
- const messageElements = Array.from(
- listElement.querySelectorAll('[data-message-id]'),
- );
- const messageAnchors = messageElements.map((element) => {
- const rect = element.getBoundingClientRect();
- return {
- center: rect.top + rect.height / 2,
- element,
- offsetTop: rect.top - listTop,
- rect,
- };
+ listElement.scrollTo({
+ top: listElement.scrollHeight,
});
+ setHasNewMessages(false);
+ }, [listElement, hasMoreNewer, suppressAutoscroll]);
- const visibleMessageAnchors = messageAnchors.filter(
- ({ rect }) => rect.bottom > listTop && rect.top < listBottom,
- );
-
- const topEdgeAnchor = visibleMessageAnchors.reduce<
- (typeof visibleMessageAnchors)[number] | null
- >((closest, candidate) => {
- if (!closest || candidate.rect.top < closest.rect.top) {
- return candidate;
- }
-
- return closest;
- }, null);
-
- const centerAnchor =
- visibleMessageAnchors.find(
- ({ rect }) => rect.top <= listCenter && rect.bottom >= listCenter,
- ) ??
- visibleMessageAnchors.reduce<(typeof visibleMessageAnchors)[number] | null>(
- (closest, candidate) => {
- if (!closest) return candidate;
-
- const distance = Math.abs(candidate.center - listCenter);
- const closestDistance = Math.abs(closest.center - listCenter);
-
- return distance < closestDistance ? candidate : closest;
- },
- null,
- );
-
- const anchor =
- (preferTopEdgeAnchor ? topEdgeAnchor : centerAnchor) ?? messageAnchors[0];
-
- if (!anchor?.element.dataset.messageId) return null;
-
- return {
- id: anchor.element.dataset.messageId,
- offsetTop: anchor.offsetTop,
- };
- }, [listElement, loadMoreScrollThreshold, loadingMore]);
-
- const restoreAnchor = useCallback(
- (anchor: { id: string; offsetTop: number }) => {
- if (!listElement) return;
-
- anchorRestoreCleanupRef.current?.();
-
- let cancelled = false;
- let stableFrameCount = 0;
- let frameQueued = false;
- let resizeObserver: ResizeObserver | undefined;
- // eslint-disable-next-line prefer-const
- let settleTimeoutId: ReturnType | undefined;
- let animationFrameId: number | undefined;
-
- isRestoringOlderAnchorRef.current = true;
-
- const applyAnchor = () => {
- if (cancelled) return true;
-
- const anchorElement = listElement.querySelector(
- `[data-message-id='${anchor.id}']`,
- );
- if (!anchorElement) return true;
-
- const listTop = listElement.getBoundingClientRect().top;
- const nextOffsetTop = anchorElement.getBoundingClientRect().top - listTop;
- const offsetDelta = nextOffsetTop - anchor.offsetTop;
-
- if (Math.abs(offsetDelta) > 1) {
- listElement.scrollBy({ top: offsetDelta });
- return false;
- }
-
- return true;
- };
-
- const cleanup = () => {
- cancelled = true;
- frameQueued = false;
- isRestoringOlderAnchorRef.current = false;
- if (typeof animationFrameId !== 'undefined') {
- window.cancelAnimationFrame(animationFrameId);
- }
- if (settleTimeoutId) {
- clearTimeout(settleTimeoutId);
- }
- resizeObserver?.disconnect();
- };
-
- // Keep correcting against the same anchor until the DOM stops shifting.
- const queueNextFrame = () => {
- if (cancelled || frameQueued) return;
- frameQueued = true;
- animationFrameId = window.requestAnimationFrame(() => {
- frameQueued = false;
- const isStable = applyAnchor();
- stableFrameCount = isStable ? stableFrameCount + 1 : 0;
-
- if (stableFrameCount >= 2) {
- cleanup();
- return;
- }
-
- queueNextFrame();
- });
- };
-
- stableFrameCount = applyAnchor() ? 1 : 0;
- queueNextFrame();
-
- // Late media/layout updates can still move the anchor after the first
- // correction, so restart the settle check when the list resizes.
- if (typeof ResizeObserver !== 'undefined') {
- resizeObserver = new ResizeObserver(() => {
- stableFrameCount = 0;
- queueNextFrame();
- });
- resizeObserver.observe(listElement);
- }
-
- settleTimeoutId = setTimeout(() => {
- cleanup();
- }, 1200);
-
- anchorRestoreCleanupRef.current = cleanup;
- },
- [listElement],
- );
-
- useLayoutEffect(
- () => () => {
- anchorRestoreCleanupRef.current?.();
- },
- [],
- );
-
- const scrollToBottom = useCallback(
- (options?: ScrollToOptions) => {
- if (
- !listElement?.scrollTo ||
- hasMoreNewer ||
- isRestoringOlderAnchorRef.current ||
- justReachedLatestMessageSet ||
- suppressAutoscroll
- ) {
- return;
- }
-
- listElement.scrollTo({
- behavior: options?.behavior,
- top: listElement.scrollHeight,
- });
- setHasNewMessages(false);
- },
- [hasMoreNewer, justReachedLatestMessageSet, listElement, suppressAutoscroll],
- );
-
- /**
- * Keeps wrapper geometry up to date and handles the "reached latest merged set"
- * path where existing viewport position must be preserved.
- */
useLayoutEffect(() => {
- const disableAutoScrollJustReleased =
- previousDisableAutoScrollToBottomRef.current && !disableAutoScrollToBottom;
- previousDisableAutoScrollToBottomRef.current = disableAutoScrollToBottom;
-
- // Re-enabling auto-scroll should not immediately force a jump to bottom.
- // This avoids snap-back after temporary suppression (e.g. jump-to-message).
- if (disableAutoScrollJustReleased) {
- return;
- }
-
if (listElement) {
setWrapperRect(listElement.getBoundingClientRect());
- }
-
- if (listElement && justReachedLatestMessageSet) {
- listElement.scrollTop = previousScrollTopRef.current;
- skipNextAutoScrollRef.current = true;
- return;
- }
-
- if (skipNextAutoScrollRef.current) {
- skipNextAutoScrollRef.current = false;
- return;
- }
-
- if (listElement && !disableAutoScrollToBottom && !isRestoringOlderAnchorRef.current) {
scrollToBottom();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [disableAutoScrollToBottom, justReachedLatestMessageSet, listElement, hasMoreNewer]);
+ }, [listElement, hasMoreNewer]);
- /**
- * Short post-render bottom settle. This is intentionally small (immediate + 2 retries)
- * to catch late layout updates without keeping the list in a prolonged lock loop.
- */
useLayoutEffect(() => {
- const disableAutoScrollJustReleased =
- previousDisableAutoScrollSettleRef.current && !disableAutoScrollToBottom;
- previousDisableAutoScrollSettleRef.current = disableAutoScrollToBottom;
-
- // Skip one settle cycle when auto-scroll suppression is released.
- // Without this guard, a jump-to-message flow can scroll to the target and then
- // get pulled back down by the delayed "keep pinned to bottom" retries
- // (80/260/420/900/1700ms), which looks like a snap-back to the latest message.
- // Letting this transition frame pass preserves the jump destination.
- if (disableAutoScrollJustReleased) {
+ if (messages.length === 0) {
+ initialDataAutoscrollDoneRef.current = false;
return;
}
if (
- !listElement ||
- disableAutoScrollToBottom ||
- hasMoreNewer ||
- suppressAutoscroll ||
- justReachedLatestMessageSet ||
- isRestoringOlderAnchorRef.current
+ listElement?.scrollTo &&
+ !initialDataAutoscrollDoneRef.current &&
+ !suppressAutoscroll
) {
- return;
- }
-
- const initialDistanceToBottom =
- listElement.scrollHeight - (listElement.scrollTop + listElement.clientHeight);
- const messagesHydrated =
- previousMessagesLengthRef.current === 0 && messages.length > 0;
-
- if (initialDistanceToBottom > scrolledUpThreshold && !messagesHydrated) {
- return;
+ listElement.scrollTo({ top: listElement.scrollHeight });
+ setHasNewMessages(false);
+ initialDataAutoscrollDoneRef.current = true;
}
-
- let keepPinnedToBottom = true;
-
- const maybeScrollToBottom = () => {
- if (keepPinnedToBottom) {
- scrollToBottom();
- }
- };
-
- maybeScrollToBottom();
- const settleDelays = [80, messagesHydrated ? 260 : 420, 900, 1700];
- const settleTimeoutIds = settleDelays.map((delay) =>
- setTimeout(maybeScrollToBottom, delay),
- );
-
- const stopKeepingPinnedToBottom = () => {
- keepPinnedToBottom = false;
- };
-
- // Any direct user interaction with the scroller disables the temporary
- // initial-load pin, so manual scrolling is never force-overridden.
- listElement.addEventListener('pointerdown', stopKeepingPinnedToBottom, {
- passive: true,
- });
- listElement.addEventListener('touchstart', stopKeepingPinnedToBottom, {
- passive: true,
- });
- listElement.addEventListener('wheel', stopKeepingPinnedToBottom, {
- passive: true,
- });
- listElement.addEventListener('keydown', stopKeepingPinnedToBottom);
-
- const pinWindowTimeoutId = setTimeout(() => {
- stopKeepingPinnedToBottom();
- }, 2200);
-
- return () => {
- settleTimeoutIds.forEach(clearTimeout);
- clearTimeout(pinWindowTimeoutId);
- listElement.removeEventListener('pointerdown', stopKeepingPinnedToBottom);
- listElement.removeEventListener('touchstart', stopKeepingPinnedToBottom);
- listElement.removeEventListener('wheel', stopKeepingPinnedToBottom);
- listElement.removeEventListener('keydown', stopKeepingPinnedToBottom);
- };
- }, [
- disableAutoScrollToBottom,
- hasMoreNewer,
- justReachedLatestMessageSet,
- listElement,
- messages.length,
- scrollToBottom,
- scrolledUpThreshold,
- suppressAutoscroll,
- ]);
+ }, [listElement, messages.length, suppressAutoscroll]);
const updateScrollTop = useMessageListScrollManager({
- captureAnchor,
- disableScrollManagement: disableScrollManagement || isRestoringOlderAnchorRef.current,
- justReachedLatestMessageSet,
- loadingMore,
+ // MERGE-RECONCILE: master's useMessageListScrollManager expects anchor-based restoration
+ // params (captureAnchor/restoreAnchor/onScrollToTop) that PR #2909's useScrollLocationLogic
+ // does not compute. captureAnchor returns null so the manager falls back to its
+ // height-delta compensation (no precise anchor restoration). Reconcile the two scroll
+ // paths if precise anchor preservation on older-page loads is required.
+ captureAnchor: () => null,
loadMoreScrollThreshold,
messages,
onScrollBy: (scrollBy) => {
listElement?.scrollBy({ top: scrollBy });
},
onScrollToTop: () => {
- if (!listElement) return;
- listElement.scrollTop = 0;
+ listElement?.scrollTo({ top: 0 });
},
- restoreAnchor,
+ restoreAnchor: () => undefined,
scrollContainerMeasures: () => ({
offsetHeight: listElement?.offsetHeight || 0,
scrollHeight: listElement?.scrollHeight || 0,
+ // MERGE-RECONCILE: master's ContainerMeasures requires scrollTop (used by the
+ // anchor-based useMessageListScrollManager); PR #2909's version omitted it.
scrollTop: listElement?.scrollTop || 0,
}),
scrolledUpThreshold,
scrollToBottom,
showNewMessages: () => setHasNewMessages(true),
+ suppressAutoscroll,
});
- useLayoutEffect(() => {
- previousHasMoreNewerRef.current = hasMoreNewer;
- }, [hasMoreNewer]);
-
- useLayoutEffect(() => {
- previousMessagesLengthRef.current = messages.length;
- }, [messages.length]);
-
- /**
- * Updates cached scroll metrics and bottom/top proximity state used by
- * notifications, autoscroll decisions, and paginator behavior.
- */
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;
- previousScrollTopRef.current = scrollTop;
- updateScrollTop(scrollTop, captureAnchor);
+ updateScrollTop(scrollTop);
const offsetHeight = element.offsetHeight;
const scrollHeight = element.scrollHeight;
- const distanceToBottom = scrollHeight - (scrollTop + offsetHeight);
- const bottomEnterThreshold = Math.max(Math.floor(scrolledUpThreshold * 0.6), 24);
const prevCloseToBottom = closeToBottom.current;
- closeToBottom.current = prevCloseToBottom
- ? distanceToBottom < scrolledUpThreshold
- : distanceToBottom < bottomEnterThreshold;
+ closeToBottom.current =
+ scrollHeight - (scrollTop + offsetHeight) < scrolledUpThreshold;
closeToTop.current = scrollTop < scrolledUpThreshold;
if (closeToBottom.current) {
@@ -440,7 +122,7 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
setIsMessageListScrolledToBottom(true);
}
},
- [captureAnchor, updateScrollTop, closeToTop, closeToBottom, scrolledUpThreshold],
+ [updateScrollTop, closeToTop, closeToBottom, scrolledUpThreshold],
);
return {
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__/useMessageListScrollManager.test.tsx b/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx
index 12a4bc1f00..2a74f3bece 100644
--- a/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx
+++ b/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx
@@ -231,4 +231,46 @@ describe('useMessageListScrollManager', () => {
expect(scrollToBottom).toHaveBeenCalledTimes(1);
});
+
+ it('does not emit scroll to bottom when suppressAutoscroll is enabled', () => {
+ const scrollToBottom = vi.fn();
+ const showNewMessages = vi.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 5307f562ac..99fc860611 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,68 +12,67 @@ 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;
- // Thread replies that are not shown in the channel must not affect the
- // main channel's unread UI state (count / notification).
- if (!mainChannelUpdated) 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 (shouldMarkRead()) {
+ // MERGE-RECONCILE: this is the fix/prevent-unread-indicator-threads scenario
+ // (this branch's origin). Master's manual setChannelUnreadUiState mechanism was
+ // removed by PR #2909; unread state is now driven by messagePaginator.
+ // unreadStateSnapshot, and the thread-vs-channel guard above. Verify at runtime
+ // that thread replies do NOT bump the channel's unread UI under the paginator model.
+ if (shouldMarkRead()) {
markRead();
}
};
@@ -96,22 +93,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 5b1a41036b..aba131be6d 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;
@@ -376,8 +389,6 @@ export const getIsFirstUnreadMessage = ({
return (
firstUnreadMessageId === message.id ||
- (!!unreadMessageCount &&
- messageIsUnread &&
- (isFirstMessage || previousMessageIsLastRead))
+ (messageIsUnread && (isFirstMessage || previousMessageIsLastRead))
);
};
diff --git a/src/components/Notifications/hooks/useNotificationTarget.ts b/src/components/Notifications/hooks/useNotificationTarget.ts
index f66675da0a..0fe9f56b40 100644
--- a/src/components/Notifications/hooks/useNotificationTarget.ts
+++ b/src/components/Notifications/hooks/useNotificationTarget.ts
@@ -1,7 +1,11 @@
import { useContext } from 'react';
import { ChatViewContext } from '../../ChatView';
-import { useChannelListContext, useChannelStateContext } from '../../../context';
+import { useChannelListContext } from '../../../context';
+// MERGE-RECONCILE: the deleted ChannelStateContext's `channel` is read here only to detect
+// whether a channel is in scope; migrated to useChannelInstanceContext (safe — returns
+// undefined channel outside a Channel subtree, unlike useChannel which throws).
+import { useChannelInstanceContext } from '../../../context/ChannelInstanceContext';
import { useThreadContext } from '../../Threads/ThreadContext';
import type { NotificationTargetPanel } from '../notificationTarget';
@@ -12,14 +16,14 @@ import { useLegacyThreadContext } from '../../Thread';
*/
export const useNotificationTarget = (): NotificationTargetPanel | undefined => {
const chatViewContext = useContext(ChatViewContext);
- const { channels } = useChannelListContext();
- const { channel } = useChannelStateContext();
+ const { paginator } = useChannelListContext();
+ const { channel } = useChannelInstanceContext();
const threadInstance = useThreadContext();
const { legacyThread } = useLegacyThreadContext();
if (threadInstance || legacyThread) return 'thread';
if (channel) return 'channel';
if (chatViewContext?.activeChatView === 'threads') return 'thread-list';
- if (channels) return 'channel-list';
+ if (paginator) return 'channel-list';
return undefined;
};
diff --git a/src/components/Poll/PollActions/PollActions.tsx b/src/components/Poll/PollActions/PollActions.tsx
index 7eb1c35b48..59524354b3 100644
--- a/src/components/Poll/PollActions/PollActions.tsx
+++ b/src/components/Poll/PollActions/PollActions.tsx
@@ -11,13 +11,14 @@ import { PollAnswerList as DefaultPollAnswerList } from './PollAnswerList';
import { PollResults as DefaultPollResults } from './PollResults';
import { 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';
@@ -56,9 +57,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 {
@@ -73,7 +75,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'), []);
@@ -82,7 +84,7 @@ export const PollActions = ({
!!voteCount ||
(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;
@@ -143,7 +145,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('PollOptionWithVotes');
+ const channelCapabilities = useChannelCapabilities({ cid: channel.cid });
const { poll } = usePollContext();
const { latest_votes_by_option } = useStateStore(poll.state, pollStateSelector);
@@ -51,7 +49,7 @@ export const PollOptionWithVotes = ({
>
{!!votes && }
- {channelCapabilities['query-poll-votes'] &&
+ {channelCapabilities.has('query-poll-votes') &&
showAllVotes &&
isVotesPreview &&
votes?.length > countVotesPreview && (
diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx
index e0330448ce..0d882135e5 100644
--- a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx
+++ b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx
@@ -1,10 +1,11 @@
import React from 'react';
import { useCanCreatePoll } from '../../MessageComposer/hooks/useCanCreatePoll';
import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController';
-import { useMessageComposerContext, useTranslationContext } from '../../../context';
+import { useTranslationContext } from '../../../context';
import clsx from 'clsx';
import { IconSend } from '../../Icons';
import { Prompt } from '../../Dialog';
+import { useSendMessageFn } from '../../MessageComposer/hooks/useSendMessageFn';
export type PollCreationDialogControlsProps = {
close: () => void;
@@ -14,8 +15,8 @@ export const PollCreationDialogControls = ({
close,
}: PollCreationDialogControlsProps) => {
const { t } = useTranslationContext('PollCreationDialogControls');
- const { handleSubmit: handleSubmitMessage } = useMessageComposerContext();
const messageComposer = useMessageComposerController();
+ const sendMessage = useSendMessageFn();
const canCreatePoll = useCanCreatePoll();
return (
@@ -37,7 +38,7 @@ export const PollCreationDialogControls = ({
onClick={() => {
messageComposer
.createPoll()
- .then(() => handleSubmitMessage())
+ .then(sendMessage)
.then(() => {
messageComposer.pollComposer.initState();
close();
diff --git a/src/components/Poll/PollOptionSelector.tsx b/src/components/Poll/PollOptionSelector.tsx
index 189c104d6f..20e9ee54c2 100644
--- a/src/components/Poll/PollOptionSelector.tsx
+++ b/src/components/Poll/PollOptionSelector.tsx
@@ -5,7 +5,7 @@ import type { PollOption, PollState, PollVote, VotingVisibility } from 'stream-c
import { isVoteAnswer } from 'stream-chat';
import { AvatarStack as DefaultAvatarStack } from '../Avatar';
import {
- useChannelStateContext,
+ useChannel,
useComponentContext,
useMessageContext,
usePollContext,
@@ -13,6 +13,7 @@ import {
} from '../../context';
import { useStateStore } from '../../store';
import { Checkbox } from '../Form';
+import { useChannelCapabilities } from '../Channel/hooks/useChannelCapabilities';
type AmountBarProps = {
amount: number;
@@ -60,8 +61,9 @@ export const PollOptionSelector = ({
option,
voteCountVerbose,
}: PollOptionSelectorProps) => {
+ const channel = useChannel();
const { t } = useTranslationContext();
- const { channelCapabilities = {} } = useChannelStateContext('PollOptionsShortlist');
+ const channelCapabilities = useChannelCapabilities({ cid: channel.cid });
const { message } = useMessageContext();
const { AvatarStack = DefaultAvatarStack } = useComponentContext();
const { poll } = usePollContext();
@@ -74,7 +76,7 @@ export const PollOptionSelector = ({
voting_visibility,
} = useStateStore(poll.state, pollStateSelector);
- const canCastVote = channelCapabilities['cast-poll-vote'] && !is_closed;
+ const canCastVote = channelCapabilities.has('cast-poll-vote') && !is_closed;
const isInteractive = !!canCastVote;
const isSelected = !!ownVotesByOptionId[option.id];
const winningOptionCount = maxVotedOptionIds[0]
diff --git a/src/components/Poll/hooks/usePollOptionVotesPagination.ts b/src/components/Poll/hooks/usePollOptionVotesPagination.ts
index 49052fdf04..4c4697e9c7 100644
--- a/src/components/Poll/hooks/usePollOptionVotesPagination.ts
+++ b/src/components/Poll/hooks/usePollOptionVotesPagination.ts
@@ -33,7 +33,7 @@ export const usePollOptionVotesPagination = ({
filter: paginationParams.filter,
options: !next
? paginationParams?.options
- : { ...paginationParams?.options, next },
+ : { ...paginationParams?.options, limit: 5, next },
sort: { created_at: -1, ...paginationParams?.sort },
});
return { items: votes, next: newNext };
diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx
index d489c81d30..3e1dd76893 100644
--- a/src/components/Reactions/ReactionSelectorWithButton.tsx
+++ b/src/components/Reactions/ReactionSelectorWithButton.tsx
@@ -28,6 +28,11 @@ export const ReactionSelectorWithButton = ({
const { ReactionSelector = DefaultReactionSelector } =
useComponentContext('MessageOptions');
const buttonRef = useRef>(null);
+ // MUST match the id `MessageActions` derives via `ReactionSelector.getDialogId` — it
+ // uses that to keep `.str-chat__message-options--active` applied while the reaction
+ // dialog is open. If the ids diverge, the options (and this trigger button) hide when
+ // focus moves into the portaled dialog, the reference collapses to a 0-size rect, and
+ // the popover falls back to the 8,8 corner. Derive from the same shared helper.
const dialogId = DefaultReactionSelector.getDialogId({
messageId: message.id,
threadList,
diff --git a/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx b/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx
new file mode 100644
index 0000000000..c822c86c68
--- /dev/null
+++ b/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { cleanup, render } from '@testing-library/react';
+
+import { ReactionSelectorWithButton } from '../ReactionSelectorWithButton';
+import { ReactionSelector } from '../ReactionSelector';
+
+// Regression guard (#3): the reaction dialog id used by ReactionSelectorWithButton MUST
+// equal the id MessageActions derives via `ReactionSelector.getDialogId`. MessageActions
+// uses that id to keep `.str-chat__message-options--active` applied while the reaction
+// dialog is open; if the ids diverge, the options (and the trigger button) hide when
+// focus moves into the portaled dialog, the reference collapses, and the popover falls
+// back to the 8,8 corner. This test fails if the two derivations drift apart again.
+
+const capturedDialogIds: string[] = [];
+
+vi.mock('../../../context', () => ({
+ useComponentContext: () => ({}),
+ useMessageContext: () => ({
+ isMyMessage: () => false,
+ message: { id: 'message-1' },
+ threadList: false,
+ }),
+ useTranslationContext: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock('../../Dialog', () => ({
+ DialogAnchor: () => null,
+ useDialogIsOpen: () => false,
+ useDialogOnNearestManager: ({ id }: { id: string }) => {
+ capturedDialogIds.push(id);
+ return { dialog: undefined, dialogManager: undefined };
+ },
+}));
+
+vi.mock('../../MessageActions', () => ({
+ QuickMessageActionsButton: () => null,
+}));
+
+afterEach(cleanup);
+
+describe('ReactionSelectorWithButton', () => {
+ it('opens the dialog under the same id MessageActions derives via getDialogId', () => {
+ capturedDialogIds.length = 0;
+ render( null} />);
+
+ const expectedId = ReactionSelector.getDialogId({
+ messageId: 'message-1',
+ threadList: false,
+ });
+
+ expect(capturedDialogIds).toContain(expectedId);
+ });
+});
diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx
index 87f28ed893..adf5399847 100644
--- a/src/components/Search/SearchResults/SearchResultItem.tsx
+++ b/src/components/Search/SearchResults/SearchResultItem.tsx
@@ -1,55 +1,68 @@
import React, { useCallback, useMemo } from 'react';
-import uniqBy from 'lodash.uniqby';
import type { ComponentType } from 'react';
import type { Channel, MessageResponse, User } from 'stream-chat';
import { useSearchContext } from '../SearchContext';
import { Avatar } from '../../../components/Avatar';
import { ChannelListItem } from '../../../components/ChannelListItem';
-import {
- useChannelListContext,
- useChatContext,
- useTranslationContext,
-} from '../../../context';
+import { useSlotForKey } from '../../../components/ChatView';
+import { useChatViewNavigation } from '../../../components/ChatView/ChatViewNavigationContext';
+import { useChatContext, useTranslationContext } from '../../../context';
import { DEFAULT_JUMP_TO_PAGE_SIZE } from '../../../constants/limits';
import { Timestamp } from '../../../components/Message/Timestamp';
export type ChannelSearchResultItemProps = {
item: Channel;
+ /** Overrides selection, exactly like `ChannelListItem`'s `onSelect`: when provided it runs
+ * instead of the default (open the channel into a layout slot). */
+ onSelect?: (event: React.MouseEvent) => void;
};
-export const ChannelSearchResultItem = ({ item }: ChannelSearchResultItemProps) => {
- const { setActiveChannel } = useChatContext();
- const { setChannels } = useChannelListContext();
-
- const onSelect = useCallback(() => {
- setActiveChannel(item);
- setChannels?.((channels) => uniqBy([item, ...channels], 'cid'));
- }, [item, setActiveChannel, setChannels]);
+export const ChannelSearchResultItem = ({
+ item,
+ onSelect,
+}: ChannelSearchResultItemProps) => {
+ const { open } = useChatViewNavigation();
+ const { channelPaginatorsOrchestrator } = useChatContext();
+
+ const handleSelect = useCallback(
+ (event: React.MouseEvent) => {
+ if (onSelect) {
+ onSelect(event);
+ return;
+ }
+ // Default: open the channel into a layout slot. Slot/UX choices (e.g. ctrl/⌘-click to
+ // open beside the current channel) are left to the app via `onSelect`.
+ open({ key: item.cid ?? undefined, kind: 'channel', source: item });
+ // Route the channel into the list(s) that should own it (the orchestrator dedupes by cid,
+ // inserts in sort order, and honors ownership/filters) so it appears without a re-query.
+ channelPaginatorsOrchestrator.ingestChannel(item);
+ },
+ [item, open, channelPaginatorsOrchestrator, onSelect],
+ );
return (
);
};
export type ChannelByMessageSearchResultItemProps = {
item: MessageResponse;
+ /** Overrides selection (see `ChannelSearchResultItem`); when provided it runs instead of the
+ * default (jump to the message and open its channel). */
+ onSelect?: (event: React.MouseEvent) => void;
};
export const MessageSearchResultItem = ({
item,
+ onSelect,
}: ChannelByMessageSearchResultItemProps) => {
- const {
- channel: activeChannel,
- client,
- searchController,
- setActiveChannel,
- } = useChatContext();
- const { setChannels } = useChannelListContext();
+ const { channelPaginatorsOrchestrator, client, searchController } = useChatContext();
+ const { open } = useChatViewNavigation();
const channel = useMemo(() => {
const { channel: channelData } = item;
@@ -58,18 +71,29 @@ export const MessageSearchResultItem = ({
return client.channel(type, id);
}, [client, item]);
- const onSelect = useCallback(async () => {
- if (!channel) return;
- await channel.state.loadMessageIntoState(
- item.id,
- undefined,
- DEFAULT_JUMP_TO_PAGE_SIZE,
- );
- // FIXME: message focus should be handled by yet non-existent msg list controller in client packaged
- searchController._internalState.partialNext({ focusedMessage: item });
- setActiveChannel(channel);
- setChannels?.((channels) => uniqBy([channel, ...channels], 'cid'));
- }, [channel, item, searchController, setActiveChannel, setChannels]);
+ // Active = this result's channel is currently open in a slot (by identity), not
+ // "the first channel slot".
+ const channelOpenInSlot = useSlotForKey(channel?.cid ?? undefined);
+
+ const handleSelect = useCallback(
+ async (event: React.MouseEvent) => {
+ if (onSelect) {
+ onSelect(event);
+ return;
+ }
+ if (!channel) return;
+ await channel.state.loadMessageIntoState(
+ item.id,
+ undefined,
+ DEFAULT_JUMP_TO_PAGE_SIZE,
+ );
+ // FIXME: message focus should be handled by yet non-existent msg list controller in client packaged
+ searchController._internalState.partialNext({ focusedMessage: item });
+ open({ key: channel.cid ?? undefined, kind: 'channel', source: channel });
+ channelPaginatorsOrchestrator.ingestChannel(channel);
+ },
+ [channel, item, open, searchController, channelPaginatorsOrchestrator, onSelect],
+ );
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const getLatestMessagePreview = useCallback(() => item.text!, [item]);
@@ -79,35 +103,54 @@ export const MessageSearchResultItem = ({
return (
);
};
export type UserSearchResultItemProps = {
item: User;
+ /** Overrides selection (see `ChannelSearchResultItem`); when provided it runs instead of the
+ * default (open a direct-messaging channel with the user). */
+ onSelect?: (event: React.MouseEvent) => void;
};
-export const UserSearchResultItem = ({ item }: UserSearchResultItemProps) => {
- const { client, setActiveChannel } = useChatContext();
- const { setChannels } = useChannelListContext();
+export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemProps) => {
+ const { channelPaginatorsOrchestrator, client } = useChatContext();
+ const { open } = useChatViewNavigation();
const { directMessagingChannelType } = useSearchContext();
const { t } = useTranslationContext();
- const onClick = useCallback(() => {
- const newChannel = client.channel(directMessagingChannelType, {
- members: [client.userID as string, item.id],
- });
- newChannel.watch();
- setActiveChannel(newChannel);
- setChannels?.((channels) => uniqBy([newChannel, ...channels], 'cid'));
- }, [client, item, setActiveChannel, setChannels, directMessagingChannelType]);
+ const onClick = useCallback(
+ (event: React.MouseEvent) => {
+ if (onSelect) {
+ onSelect(event);
+ return;
+ }
+ const newChannel = client.channel(directMessagingChannelType, {
+ members: [client.userID as string, item.id],
+ });
+ newChannel.watch();
+ // Default: open the DM channel into a layout slot. ctrl/⌘-click and other slot choices
+ // are left to the app via `onSelect`.
+ open({ key: newChannel.cid ?? undefined, kind: 'channel', source: newChannel });
+ channelPaginatorsOrchestrator.ingestChannel(newChannel);
+ },
+ [
+ client,
+ item,
+ open,
+ channelPaginatorsOrchestrator,
+ directMessagingChannelType,
+ onSelect,
+ ],
+ );
return (
diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx
index 39df988d37..0a311a266c 100644
--- a/src/components/Search/__tests__/SearchResultItem.test.tsx
+++ b/src/components/Search/__tests__/SearchResultItem.test.tsx
@@ -11,7 +11,6 @@ import {
import { SearchContextProvider } from '../SearchContext';
import type { SearchContextValue } from '../SearchContext';
import {
- ChannelListContextProvider,
ChatProvider,
DialogManagerProvider,
TranslationProvider,
@@ -22,16 +21,26 @@ import {
generateUser,
initChannelFromData,
initClientWithChannels,
- mockChannelListContext,
mockTranslationContextValue,
} from '../../../mock-builders';
const CHANNEL_PREVIEW_BUTTON_TEST_ID = 'channel-list-item-button';
-const mockSetActiveChannel = vi.fn().mockImplementation(() => {});
-const mockSetChannels = vi.fn().mockImplementation(() => {});
+const mockOpen = vi.fn();
+const mockIngestChannel = vi.fn();
+const mockOrchestrator = { ingestChannel: mockIngestChannel };
const directMessagingChannelType = 'X';
+// Selection opens the channel into a layout slot (one navigation model); the item's
+// "active" highlight comes from useSlotForKey (stubbed inactive here).
+vi.mock('../../ChatView', () => ({
+ useChatViewNavigation: () => ({ open: mockOpen }),
+ useSlotForKey: () => undefined,
+}));
+vi.mock('../../ChatView/ChatViewNavigationContext', () => ({
+ useChatViewNavigation: () => ({ open: mockOpen }),
+}));
+
const mockTranslation = (key: string, options?: Record
) => {
const interpolated = Object.entries(options || {}).reduce(
(value, [name, arg]) => value.replace(`{{ ${name} }}`, String(arg)),
@@ -48,6 +57,7 @@ const renderComponent = async ({
channelSearchData,
chatContext,
customClient,
+ itemProps,
messageResponseData,
SearchResultItemComponent,
userData,
@@ -81,21 +91,17 @@ const renderComponent = async ({
- ({ directMessagingChannelType })}
>
- ({ directMessagingChannelType })}
- >
-
-
-
+
+
,
@@ -123,8 +129,24 @@ describe('SearchResultItem Components', () => {
fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID));
- expect(mockSetActiveChannel.mock.calls[0][0].id).toBe(channelSearchData.channel.id);
- expect(mockSetChannels).toHaveBeenCalledTimes(1);
+ expect(mockOpen.mock.calls[0][0]).toMatchObject({ kind: 'channel' });
+ expect(mockOpen.mock.calls[0][0].source.id).toBe(channelSearchData.channel.id);
+ expect(mockIngestChannel).toHaveBeenCalledTimes(1);
+ });
+
+ it('runs a custom onSelect instead of the default open', async () => {
+ const channelSearchData = generateChannel();
+ const onSelect = vi.fn();
+ await renderComponent({
+ channelSearchData,
+ itemProps: { onSelect },
+ SearchResultItemComponent,
+ });
+
+ fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID));
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(mockOpen).not.toHaveBeenCalled();
});
});
@@ -159,10 +181,8 @@ describe('SearchResultItem Components', () => {
expect(
searchController._internalState.getLatestValue().focusedMessage,
).toStrictEqual(messageResponseData);
- expect(mockSetActiveChannel.mock.calls[0][0].id).toBe(
- messageResponseData.channel.id,
- );
- expect(mockSetChannels).toHaveBeenCalledTimes(1);
+ expect(mockOpen.mock.calls[0][0].source.id).toBe(messageResponseData.channel.id);
+ expect(mockIngestChannel).toHaveBeenCalledTimes(1);
});
it('displays message text in preview', async () => {
@@ -199,7 +219,23 @@ describe('SearchResultItem Components', () => {
await act(() => {
fireEvent.click(screen.getByRole('option'));
});
- expect(mockSetChannels).toHaveBeenCalledTimes(1);
+ expect(mockOpen.mock.calls[0][0]).toMatchObject({ kind: 'channel' });
+ expect(mockIngestChannel).toHaveBeenCalledTimes(1);
+ });
+
+ it('runs a custom onSelect instead of the default DM open', async () => {
+ const onSelect = vi.fn();
+ await renderComponent({
+ itemProps: { onSelect },
+ SearchResultItemComponent,
+ userData: user,
+ });
+
+ await act(() => {
+ fireEvent.click(screen.getByRole('option'));
+ });
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(mockOpen).not.toHaveBeenCalled();
});
it('uses user id when name is not available', async () => {
diff --git a/src/components/TextareaComposer/TextareaComposer.tsx b/src/components/TextareaComposer/TextareaComposer.tsx
index cbdf5cc23f..44d9fba2ca 100644
--- a/src/components/TextareaComposer/TextareaComposer.tsx
+++ b/src/components/TextareaComposer/TextareaComposer.tsx
@@ -24,6 +24,7 @@ import { useComponentContext, useMessageComposerContext } from '../../context';
import { useStateStore } from '../../store';
import { SuggestionList as DefaultSuggestionList } from './SuggestionList';
import { useTextareaPlaceholder } from './hooks/useTextareaPlaceholder';
+import { useSendMessageFn } from '../MessageComposer/hooks/useSendMessageFn';
const textComposerStateSelector = (state: TextComposerState) => ({
selection: state.selection,
@@ -91,7 +92,6 @@ export const TextareaComposer = ({
const {
additionalTextareaProps,
focus,
- handleSubmit,
maxRows: maxRowsContext,
minRows: minRowsContext,
onPaste,
@@ -124,6 +124,7 @@ export const TextareaComposer = ({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} satisfies React.CSSProperties);
+ const sendMessage = useSendMessageFn();
const { enabled } = useStateStore(messageComposer.configState, configStateSelector);
const { quotedMessage } = useStateStore(
@@ -229,14 +230,14 @@ export const TextareaComposer = ({
// prevent adding newline when submitting a message with
event.preventDefault();
}
- handleSubmit();
+ sendMessage();
}
},
[
focusedItemIndex,
- handleSubmit,
messageComposer,
onKeyDown,
+ sendMessage,
shouldSubmit,
textComposer,
textareaRef,
diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx
index 0be5a22050..fa00381d47 100644
--- a/src/components/Thread/Thread.tsx
+++ b/src/components/Thread/Thread.tsx
@@ -1,27 +1,42 @@
-import React, { useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
import clsx from 'clsx';
import { LegacyThreadContext } from './LegacyThreadContext';
+import { WithAudioPlayback } from '../AudioPlayback';
import { MESSAGE_ACTIONS } from '../Message';
import type { MessageComposerProps } from '../MessageComposer';
import { MessageComposer } from '../MessageComposer';
import type { MessageListProps, VirtualizedMessageListProps } from '../MessageList';
import { MessageList, VirtualizedMessageList } from '../MessageList';
+import {
+ useChatViewNavigation,
+ useSlotForKind,
+} from '../ChatView/ChatViewNavigationContext';
import { ThreadHeader as DefaultThreadHeader } from './ThreadHeader';
import { ThreadHead as DefaultThreadHead } from '../Thread/ThreadHead';
+import { useThreadSlotContext } from './ThreadSlotContext';
-import {
- useChannelActionContext,
- useChannelStateContext,
- useChatContext,
- useComponentContext,
-} from '../../context';
+import { useChatContext, useComponentContext } from '../../context';
import { useThreadContext } from '../Threads';
import { useStateStore } from '../../store';
+import { useThreadRequestHandlers } from './hooks/useThreadRequestHandlers';
import type { MessageProps, MessageUIComponentProps } from '../Message/types';
import type { MessageActionsArray } from '../Message/utils';
-import type { ThreadState } from 'stream-chat';
+import type {
+ DeleteMessageOptions,
+ EventAPIResponse,
+ LocalMessage,
+ MarkReadOptions,
+ Message,
+ MessageResponse,
+ SendMessageOptions,
+ Channel as StreamChannel,
+ StreamChat,
+ Thread as StreamThread,
+ ThreadState,
+ UpdateMessageOptions,
+} from 'stream-chat';
export type ThreadProps = {
/** Additional props for `MessageComposer` component: [available props](https://getstream.io/chat/docs/sdk/react/message-composer-components/message_composer/#props) */
@@ -32,6 +47,8 @@ export type ThreadProps = {
additionalParentMessageProps?: Partial;
/** Additional props for `VirtualizedMessageList` component: [available props](https://getstream.io/chat/docs/sdk/react/core-components/virtualized_list/#props) */
additionalVirtualizedMessageListProps?: VirtualizedMessageListProps;
+ /** Allows multiple audio players to play the audio at the same time within this thread. Disabled by default. */
+ allowConcurrentAudioPlayback?: boolean;
/** If true, focuses the `MessageComposer` component on opening a thread */
autoFocus?: boolean;
/** Injects date separator components into `Thread`, defaults to `false`. To be passed to the underlying `MessageList` or `VirtualizedMessageList` components */
@@ -40,6 +57,29 @@ export type ThreadProps = {
Message?: React.ComponentType;
/** Array of allowed message actions (ex: ['edit', 'delete', 'flag', 'mute', 'pin', 'quote', 'react', 'reply']). To disable all actions, provide an empty array. */
messageActions?: MessageActionsArray;
+ /** Custom action handler to override the default `client.deleteMessage(message.id)` function in thread flows */
+ doDeleteMessageRequest?: (
+ thread: StreamThread,
+ message: LocalMessage,
+ options?: DeleteMessageOptions,
+ ) => Promise;
+ /** Custom action handler to override the default `thread.markAsRead` request function (advanced usage only) */
+ doMarkReadRequest?: (params: {
+ thread: StreamThread;
+ options?: MarkReadOptions;
+ }) => Promise | void;
+ /** Custom action handler to override the default `channel.sendMessage` request function in thread flows */
+ doSendMessageRequest?: (
+ thread: StreamThread,
+ message: Message,
+ options?: SendMessageOptions,
+ ) => ReturnType | void;
+ /** Custom action handler to override the default `client.updateMessage` request function in thread flows */
+ doUpdateMessageRequest?: (
+ thread: StreamThread,
+ updatedMessage: LocalMessage | MessageResponse,
+ options?: UpdateMessageOptions,
+ ) => ReturnType;
/** If true, render the `VirtualizedMessageList` instead of the standard `MessageList` component */
virtualized?: boolean;
};
@@ -48,52 +88,63 @@ export type ThreadProps = {
* The Thread component renders a parent Message with a list of replies
*/
export const Thread = (props: ThreadProps) => {
- const { channel, channelConfig, thread } = useChannelStateContext('Thread');
const threadInstance = useThreadContext();
- if (!thread && !threadInstance) return null;
- if (channelConfig?.replies === false) return null;
+ if (!threadInstance) return null;
+ // todo: remove the use of channel.getConfig
+ if (threadInstance.channel.getConfig()?.replies === false) return null;
+ // todo: maybe this extra layer with ThreadInner could be removed?
// the wrapper ensures a key variable is set and the component recreates on thread switch
return (
- // FIXME: TS is having trouble here as at least one of the two would always be defined
);
};
const selector = (nextValue: ThreadState) => ({
- isLoadingNext: nextValue.pagination.isLoadingNext,
- isLoadingPrev: nextValue.pagination.isLoadingPrev,
+ isStateStale: nextValue.isStateStale,
parentMessage: nextValue.parentMessage,
- replies: nextValue.replies,
});
+const messagePaginatorSelector = ({
+ isLoading,
+ items,
+ lastQueryError,
+}: {
+ isLoading: boolean;
+ items: LocalMessage[] | undefined;
+ lastQueryError?: Error;
+}) => ({
+ isLoading,
+ items,
+ lastQueryError,
+});
+
+const threadManagerSelector = ({ threads }: { threads: StreamThread[] }) => ({ threads });
+
const ThreadInner = (props: ThreadProps & { key: string }) => {
const {
additionalMessageComposerProps,
additionalMessageListProps,
additionalParentMessageProps,
additionalVirtualizedMessageListProps,
+ allowConcurrentAudioPlayback,
autoFocus = true,
+ doDeleteMessageRequest,
+ doMarkReadRequest,
+ doSendMessageRequest,
+ doUpdateMessageRequest,
enableDateSeparator = false,
Message: PropMessage,
messageActions = Object.keys(MESSAGE_ACTIONS),
virtualized,
} = props;
const threadInstance = useThreadContext();
-
- const {
- thread,
- threadHasMore,
- threadLoadingMore,
- threadMessages = [],
- threadSuppressAutoscroll,
- } = useChannelStateContext('Thread');
- const { closeThread, loadMoreThread } = useChannelActionContext('Thread');
- const { customClasses } = useChatContext('Thread');
+ const threadSlot = useThreadSlotContext();
+ const { client, customClasses } = useChatContext('Thread');
const {
Message: ContextMessage,
ThreadHead = DefaultThreadHead,
@@ -101,51 +152,91 @@ const ThreadInner = (props: ThreadProps & { key: string }) => {
VirtualMessage,
} = useComponentContext('Thread');
- const { isLoadingNext, isLoadingPrev, parentMessage, replies } =
+ const { isStateStale, parentMessage } =
useStateStore(threadInstance?.state, selector) ?? {};
+ const threadPaginatorState = useStateStore(
+ threadInstance?.messagePaginator?.state,
+ messagePaginatorSelector,
+ );
+ const threadManagerState = useStateStore(
+ client.threads.state,
+ threadManagerSelector,
+ ) ?? {
+ threads: client.threads.state.getLatestValue().threads,
+ };
+ const isThreadManaged = threadInstance?.id
+ ? threadManagerState.threads.some(
+ (managedThread) => managedThread.id === threadInstance.id,
+ )
+ : false;
+
+ const { close } = useChatViewNavigation();
+ const activeThreadSlot = useSlotForKind('thread');
+ const closableThreadSlot = threadSlot ?? activeThreadSlot;
+
+ const closeThread = useCallback(() => {
+ if (closableThreadSlot) close(closableThreadSlot);
+ // Keep legacy behavior when Thread is used outside ChatView navigation flow.
+ threadInstance?.deactivate();
+ }, [close, closableThreadSlot, threadInstance]);
const ThreadMessage = PropMessage || additionalMessageListProps?.Message;
const FallbackMessage = virtualized && VirtualMessage ? VirtualMessage : ContextMessage;
const MessageUIComponent = ThreadMessage || FallbackMessage;
const ThreadMessageList = virtualized ? VirtualizedMessageList : MessageList;
+ useThreadRequestHandlers({
+ doDeleteMessageRequest,
+ doMarkReadRequest,
+ doSendMessageRequest,
+ doUpdateMessageRequest,
+ threadInstance,
+ });
useEffect(() => {
- if (threadInstance) return;
+ if (!threadInstance) return;
+ if (isThreadManaged) return;
+ if (threadPaginatorState?.items !== undefined || threadPaginatorState?.isLoading)
+ return;
+ void threadInstance.reload();
+ }, [
+ isThreadManaged,
+ threadInstance,
+ threadPaginatorState?.isLoading,
+ threadPaginatorState?.items,
+ ]);
- if ((thread?.reply_count ?? 0) > 0) {
- // FIXME: integrators can customize channel query options but cannot customize channel.getReplies() options
- loadMoreThread();
+ useEffect(() => {
+ if (threadInstance && isStateStale) {
+ void threadInstance.reload();
}
- }, [thread, loadMoreThread, threadInstance]);
-
- const threadProps: Pick<
- VirtualizedMessageListProps,
- | 'hasMoreNewer'
- | 'loadMoreNewer'
- | 'loadingMoreNewer'
- | 'hasMore'
- | 'loadMore'
- | 'loadingMore'
- | 'messages'
- > = threadInstance
- ? {
- loadingMore: isLoadingPrev,
- loadingMoreNewer: isLoadingNext,
- loadMore: threadInstance.loadPrevPage,
- loadMoreNewer: threadInstance.loadNextPage,
- messages: replies,
+ }, [isStateStale, threadInstance]);
+
+ useEffect(() => {
+ if (!threadInstance || isThreadManaged) return;
+ if (threadPaginatorState?.isLoading) return;
+ if (threadPaginatorState?.lastQueryError) return;
+ if (threadPaginatorState?.items === undefined) return;
+
+ client.threads.state.next((current) => {
+ if (current.threads.some((thread) => thread.id === threadInstance.id)) {
+ return current;
}
- : {
- hasMore: threadHasMore,
- loadingMore: threadLoadingMore,
- loadMore: loadMoreThread,
- messages: threadMessages,
+ return {
+ ...current,
+ threads: [threadInstance, ...current.threads],
};
+ });
+ }, [
+ client.threads.state,
+ isThreadManaged,
+ threadInstance,
+ threadPaginatorState?.isLoading,
+ threadPaginatorState?.items,
+ threadPaginatorState?.lastQueryError,
+ ]);
- const messageAsThread = thread ?? parentMessage;
-
- if (!messageAsThread) return null;
+ if (!threadInstance || !parentMessage) return null;
const threadClass =
customClasses?.thread ||
@@ -155,8 +246,8 @@ const ThreadInner = (props: ThreadProps & { key: string }) => {
const head = (
@@ -166,29 +257,31 @@ const ThreadInner = (props: ThreadProps & { key: string }) => {
// Thread component needs a context which we can use for message composer
-
-
-
-
-
+ {/* The thread owns its audio-player pool (rather than inheriting one from an ambient
+ ) because a slot-bound Thread is a sibling of the channel, not nested inside
+ it. Scoping the pool here means thread audio stops when the thread unmounts. */}
+
+
+
+
+
+
+
);
};
diff --git a/src/components/Thread/ThreadHead.tsx b/src/components/Thread/ThreadHead.tsx
index 28490d525b..0bcd1e6a4e 100644
--- a/src/components/Thread/ThreadHead.tsx
+++ b/src/components/Thread/ThreadHead.tsx
@@ -12,7 +12,7 @@ export const ThreadHead = (props: MessageProps) => {
return (
-
+
);
diff --git a/src/components/Thread/ThreadHeader.tsx b/src/components/Thread/ThreadHeader.tsx
index f61303e623..b6f8e90a2c 100644
--- a/src/components/Thread/ThreadHeader.tsx
+++ b/src/components/Thread/ThreadHeader.tsx
@@ -1,22 +1,25 @@
import React from 'react';
-import { useChannelStateContext } from '../../context/ChannelStateContext';
+import { useChannel } from '../../context';
import { useTranslationContext } from '../../context/TranslationContext';
import { useStateStore } from '../../store';
+import { useChannelConfig } from '../Channel/hooks/useChannelConfig';
import { useChannelPreviewInfo } from '../ChannelListItem/hooks/useChannelPreviewInfo';
+import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
import { TypingIndicatorHeader } from '../TypingIndicator/TypingIndicatorHeader';
import { useThreadContext } from '../Threads';
import { useChatContext } from '../../context/ChatContext';
import { useComponentContext } from '../../context/ComponentContext';
-import { useTypingContext } from '../../context/TypingContext';
import type { LocalMessage } from 'stream-chat';
-import type { ThreadState } from 'stream-chat';
+import type { TextComposerState, ThreadState } from 'stream-chat';
import { Button } from '../Button';
import { IconXmark } from '../Icons';
-import { useChatViewContext } from '../ChatView';
+import { useChatViewContext, useSlotForKind } from '../ChatView';
+import { useThreadSlotContext } from './ThreadSlotContext';
const threadStateSelector = ({ replyCount }: ThreadState) => ({ replyCount });
+const textComposerTypingSelector = ({ typing }: TextComposerState) => ({ typing });
/** Fallback when channel has no display title: parent message author (name only). */
const displayNameFromParentMessage = (message: LocalMessage): string | undefined =>
@@ -33,11 +36,14 @@ const ThreadHeaderSubtitle = ({
threadList: boolean;
}) => {
const { t } = useTranslationContext();
- const { channelConfig, thread } = useChannelStateContext('ThreadHeaderSubtitle');
+ const channel = useChannel();
+ const channelConfig = useChannelConfig({ cid: channel?.cid });
const threadInstance = useThreadContext();
- const parentId = threadInstance?.id ?? thread?.id;
+ const parentId = threadInstance?.id;
const { client } = useChatContext('ThreadHeaderSubtitle');
- const { typing = {} } = useTypingContext('ThreadHeaderSubtitle');
+ const messageComposer = useMessageComposerController();
+ const { typing = {} } =
+ useStateStore(messageComposer.textComposer?.state, textComposerTypingSelector) ?? {};
const typingInThread = Object.values(typing).filter(
({ parent_id, user }) => user?.id !== client.user?.id && parent_id === parentId,
);
@@ -75,9 +81,19 @@ export const ThreadHeader = (props: ThreadHeaderProps) => {
const { closeThread, overrideTitle, thread } = props;
const { t } = useTranslationContext();
- const { channel } = useChannelStateContext();
+ const channel = useChannel();
const { HeaderStartContent } = useComponentContext();
const { activeChatView } = useChatViewContext();
+ const activeThreadSlot = useSlotForKind('thread');
+ const threadSlot = useThreadSlotContext();
+ // A thread occupying a slot other than the view's first thread slot is a *secondary* thread
+ // (a 2nd thread opened beside the main one).
+ const isSecondaryThread =
+ !!threadSlot && !!activeThreadSlot && threadSlot !== activeThreadSlot;
+ // Show the close button for reply threads rendered in a side panel (any non-threads view)
+ // and for secondary threads in the threads view. It is hidden for the threads view's primary
+ // thread, which is the main panel — you switch views rather than close it.
+ const showCloseButton = activeChatView !== 'threads' || isSecondaryThread;
const { displayTitle: channelDisplayTitle } = useChannelPreviewInfo({ channel });
const threadInstance = useThreadContext();
@@ -110,7 +126,11 @@ export const ThreadHeader = (props: ThreadHeaderProps) => {
threadList
/>
- {!threadInstance && (
+ {/* The close button releases the thread's slot, so it is shown for threads that live in
+ a closable side panel: reply threads in any non-threads view, and secondary threads
+ in the threads view. It is hidden for the threads view's primary thread (the main
+ panel) — see `showCloseButton` above. */}
+ {showCloseButton && (
;
+
+export const ThreadSlot = ({
+ children,
+ fallback = null,
+ hideIfEmpty = true,
+ slot,
+ ...threadProps
+}: ThreadSlotProps) => {
+ const { layoutController } = useChatViewContext();
+ const thread = useSlotThread({ slot });
+
+ useEffect(() => {
+ if (!slot || !hideIfEmpty) return;
+ if (thread) layoutController.unhide(slot);
+ else layoutController.hide(slot);
+ }, [hideIfEmpty, layoutController, slot, thread]);
+
+ if (!thread) return <>{fallback}>;
+
+ return (
+
+
+ {children ?? }
+
+
+ );
+};
diff --git a/src/components/Thread/ThreadSlotContext.tsx b/src/components/Thread/ThreadSlotContext.tsx
new file mode 100644
index 0000000000..ef3141c0d3
--- /dev/null
+++ b/src/components/Thread/ThreadSlotContext.tsx
@@ -0,0 +1,7 @@
+import { createContext, useContext } from 'react';
+
+import type { SlotName } from '../ChatView/layoutController/layoutControllerTypes';
+
+export const ThreadSlotContext = createContext(undefined);
+
+export const useThreadSlotContext = () => useContext(ThreadSlotContext);
diff --git a/src/components/Thread/ThreadStart.tsx b/src/components/Thread/ThreadStart.tsx
index a800093f71..8925586cfa 100644
--- a/src/components/Thread/ThreadStart.tsx
+++ b/src/components/Thread/ThreadStart.tsx
@@ -1,31 +1,24 @@
import React from 'react';
-import { useChannelStateContext } from '../../context/ChannelStateContext';
import { useTranslationContext } from '../../context/TranslationContext';
-import { useThreadContext } from '../Threads';
+import { useThreadContext } from '../Threads/ThreadContext';
import { useStateStore } from '../../store';
import type { ThreadState } from 'stream-chat';
-const threadStateSelector = ({ replyCount }: ThreadState) => ({
- replyCount,
+const threadStartSelector = ({ parentMessage }: ThreadState) => ({
+ parentMessage,
});
export const ThreadStart = () => {
- const { thread } = useChannelStateContext('ThreadStart');
+ const thread = useThreadContext();
const { t } = useTranslationContext('ThreadStart');
- const threadInstance = useThreadContext();
- const { replyCount: replyCountThreadInstance } =
- useStateStore(threadInstance?.state, threadStateSelector) ?? {};
+ const { parentMessage } = useStateStore(thread?.state, threadStartSelector) ?? {};
- const replyCount = threadInstance
- ? replyCountThreadInstance
- : thread
- ? (thread.reply_count ?? 0)
- : 0;
-
- if (!replyCount) return null;
+ if (!parentMessage?.reply_count) return null;
return (
- {t('replyCount', { count: replyCount })}
+
+ {t('replyCount', { count: parentMessage.reply_count })}
+
);
};
diff --git a/src/components/Thread/__tests__/ThreadHeader.test.tsx b/src/components/Thread/__tests__/ThreadHeader.test.tsx
index 6880d99a9b..5f751bd68a 100644
--- a/src/components/Thread/__tests__/ThreadHeader.test.tsx
+++ b/src/components/Thread/__tests__/ThreadHeader.test.tsx
@@ -33,6 +33,7 @@ vi.mock('../../Threads', () => ({
vi.mock('../../ChatView', () => ({
useChatViewContext: vi.fn(() => ({ activeChatView: 'channels' })),
+ useSlotForKind: vi.fn(() => undefined),
}));
import { useChannelPreviewInfo } from '../../ChannelListItem/hooks/useChannelPreviewInfo';
diff --git a/src/components/Thread/hooks/__tests__/useThreadRequestHandlers.test.ts b/src/components/Thread/hooks/__tests__/useThreadRequestHandlers.test.ts
new file mode 100644
index 0000000000..23f8022b50
--- /dev/null
+++ b/src/components/Thread/hooks/__tests__/useThreadRequestHandlers.test.ts
@@ -0,0 +1,102 @@
+import { renderHook } from '@testing-library/react';
+
+import { useThreadRequestHandlers } from '../useThreadRequestHandlers';
+
+const createThreadStub = () => {
+ let channelValue: { requestHandlers?: Record } = {};
+ let threadValue: { requestHandlers?: Record } = {};
+
+ const channel = {
+ cid: 'messaging:test',
+ configState: {
+ getLatestValue: () => channelValue,
+ partialNext: (update: { requestHandlers?: Record }) => {
+ channelValue = { ...channelValue, ...update };
+ },
+ },
+ getClient: () => ({
+ deleteMessage: vi.fn().mockResolvedValue({ message: { id: 'fallback-delete' } }),
+ updateMessage: vi.fn().mockResolvedValue({ message: { id: 'fallback-update' } }),
+ }),
+ markAsReadRequest: vi.fn().mockResolvedValue(null),
+ sendMessage: vi.fn().mockResolvedValue({ message: { id: 'fallback-send' } }),
+ } as const;
+
+ const thread = {
+ channel,
+ configState: {
+ getLatestValue: () => threadValue,
+ partialNext: (update: { requestHandlers?: Record }) => {
+ threadValue = { ...threadValue, ...update };
+ },
+ },
+ id: 'parent-1',
+ } as const;
+
+ return {
+ getChannelRequestHandlers: () => channelValue.requestHandlers,
+ getThreadRequestHandlers: () => threadValue.requestHandlers,
+ thread,
+ };
+};
+
+describe('useThreadRequestHandlers', () => {
+ it('wires only one send/retry handler and passes parent_id to custom handler', async () => {
+ const { getChannelRequestHandlers, thread } = createThreadStub();
+
+ const doSendMessageRequest = vi
+ .fn()
+ .mockResolvedValue({ message: { id: 'custom-thread-send' } });
+
+ renderHook(() =>
+ useThreadRequestHandlers({
+ doSendMessageRequest: doSendMessageRequest as never,
+ threadInstance: thread as never,
+ }),
+ );
+
+ const handlers = getChannelRequestHandlers();
+ expect(handlers?.sendMessageRequest).toBeDefined();
+ expect(handlers?.retrySendMessageRequest).toBe(handlers?.sendMessageRequest);
+
+ const sendRequest = handlers?.sendMessageRequest as (params: {
+ localMessage: { id: string; parent_id?: string };
+ message?: { id?: string; parent_id?: string };
+ options?: { skip_push?: boolean; parent_id?: string };
+ }) => Promise<{ message: { id: string } }>;
+
+ await sendRequest({
+ localMessage: { id: 'm-1', parent_id: 'parent-1' },
+ message: { id: 'm-1' },
+ options: { skip_push: true },
+ });
+
+ expect(doSendMessageRequest).toHaveBeenCalledTimes(1);
+ expect(doSendMessageRequest).toHaveBeenCalledWith(
+ thread,
+ expect.objectContaining({ parent_id: 'parent-1' }),
+ expect.objectContaining({ parent_id: 'parent-1' }),
+ );
+ });
+
+ it('removes managed handler when custom handler is unset', () => {
+ const { getThreadRequestHandlers, thread } = createThreadStub();
+
+ const doMarkReadRequest = vi.fn();
+
+ const { rerender } = renderHook(
+ ({ doMarkReadRequest }) =>
+ useThreadRequestHandlers({
+ doMarkReadRequest: doMarkReadRequest as never,
+ threadInstance: thread as never,
+ }),
+ { initialProps: { doMarkReadRequest } },
+ );
+
+ expect(getThreadRequestHandlers()?.markReadRequest).toBeDefined();
+
+ rerender({ doMarkReadRequest: undefined });
+
+ expect(getThreadRequestHandlers()?.markReadRequest).toBeUndefined();
+ });
+});
diff --git a/src/components/Thread/hooks/useThreadRequestHandlers.ts b/src/components/Thread/hooks/useThreadRequestHandlers.ts
new file mode 100644
index 0000000000..460af6d94c
--- /dev/null
+++ b/src/components/Thread/hooks/useThreadRequestHandlers.ts
@@ -0,0 +1,186 @@
+import { useEffect } from 'react';
+import type {
+ DeleteMessageOptions,
+ EventAPIResponse,
+ LocalMessage,
+ MarkReadOptions,
+ Message,
+ MessageResponse,
+ SendMessageOptions,
+ Channel as StreamChannel,
+ StreamChat,
+ Thread as StreamThread,
+ UpdateMessageOptions,
+} from 'stream-chat';
+
+export type ThreadRequestHandlersParams = {
+ threadInstance?: StreamThread;
+ doDeleteMessageRequest?: (
+ thread: StreamThread,
+ message: LocalMessage,
+ options?: DeleteMessageOptions,
+ ) => Promise;
+ doMarkReadRequest?: (params: {
+ thread: StreamThread;
+ options?: MarkReadOptions;
+ }) => Promise | void;
+ doSendMessageRequest?: (
+ thread: StreamThread,
+ message: Message,
+ options?: SendMessageOptions,
+ ) => ReturnType | void;
+ doUpdateMessageRequest?: (
+ thread: StreamThread,
+ updatedMessage: LocalMessage | MessageResponse,
+ options?: UpdateMessageOptions,
+ ) => ReturnType;
+};
+
+export const useThreadRequestHandlers = ({
+ doDeleteMessageRequest,
+ doMarkReadRequest,
+ doSendMessageRequest,
+ doUpdateMessageRequest,
+ threadInstance,
+}: ThreadRequestHandlersParams) => {
+ useEffect(() => {
+ if (!threadInstance) return;
+
+ const channel = threadInstance.channel;
+ const threadConfigState = threadInstance.configState;
+ const channelConfigState = channel.configState;
+ const currentThreadRequestHandlers = threadConfigState?.getLatestValue()
+ .requestHandlers as Record | undefined;
+ const nextThreadRequestHandlers = {
+ ...(currentThreadRequestHandlers ?? {}),
+ } as Record;
+ const currentRequestHandlers = channelConfigState?.getLatestValue()
+ .requestHandlers as Record | undefined;
+ const nextRequestHandlers = { ...(currentRequestHandlers ?? {}) } as Record<
+ string,
+ unknown
+ >;
+
+ const withParentId = (options: TOptions): TOptions =>
+ ({ ...(options as object), parent_id: threadInstance.id }) as TOptions;
+
+ const isMessageFromThread = (message?: Pick) =>
+ !!message &&
+ (message.parent_id === threadInstance.id || message.id === threadInstance.id);
+
+ // Reset managed operation handlers and register only currently provided custom handlers.
+ delete nextRequestHandlers.deleteMessageRequest;
+ delete nextRequestHandlers.retrySendMessageRequest;
+ delete nextRequestHandlers.sendMessageRequest;
+ delete nextRequestHandlers.updateMessageRequest;
+ delete nextThreadRequestHandlers.markReadRequest;
+
+ if (doDeleteMessageRequest) {
+ nextRequestHandlers.deleteMessageRequest = async (params: {
+ localMessage: LocalMessage;
+ options?: DeleteMessageOptions;
+ }) => {
+ if (isMessageFromThread(params.localMessage)) {
+ const message = await doDeleteMessageRequest(
+ threadInstance,
+ params.localMessage,
+ withParentId(params.options),
+ );
+ return { message };
+ }
+
+ const fallback = await channel
+ .getClient()
+ .deleteMessage(params.localMessage.id, params.options);
+ return { message: fallback.message };
+ };
+ }
+
+ if (doSendMessageRequest) {
+ const sendMessageRequest = async (params: {
+ localMessage: LocalMessage;
+ message?: Message;
+ options?: SendMessageOptions;
+ }) => {
+ const sourceMessage = params.message ?? params.localMessage;
+ const targetMessage = {
+ ...sourceMessage,
+ parent_id: sourceMessage.parent_id ?? threadInstance.id,
+ } as Message;
+
+ if (isMessageFromThread(params.localMessage)) {
+ const response = await doSendMessageRequest(
+ threadInstance,
+ targetMessage,
+ withParentId(params.options),
+ );
+ if (response?.message) return { message: response.message };
+ }
+
+ const fallback = await channel.sendMessage(targetMessage, params.options);
+ return { message: fallback.message };
+ };
+
+ nextRequestHandlers.sendMessageRequest = sendMessageRequest;
+ nextRequestHandlers.retrySendMessageRequest = sendMessageRequest;
+ }
+
+ if (doUpdateMessageRequest) {
+ nextRequestHandlers.updateMessageRequest = async (params: {
+ localMessage: LocalMessage;
+ options?: UpdateMessageOptions;
+ }) => {
+ if (isMessageFromThread(params.localMessage)) {
+ const response = await doUpdateMessageRequest(
+ threadInstance,
+ {
+ ...params.localMessage,
+ parent_id: params.localMessage.parent_id ?? threadInstance.id,
+ },
+ withParentId(params.options),
+ );
+ return { message: response.message };
+ }
+
+ const fallback = await channel
+ .getClient()
+ .updateMessage(params.localMessage, undefined, params.options);
+ return { message: fallback.message };
+ };
+ }
+
+ if (doMarkReadRequest) {
+ nextThreadRequestHandlers.markReadRequest = async (params: {
+ thread: StreamThread;
+ options?: MarkReadOptions;
+ }) => {
+ const response = await doMarkReadRequest({
+ options: params.options,
+ thread: params.thread,
+ });
+ if (response !== undefined) return response;
+ return await params.thread.channel.markAsReadRequest({
+ ...params.options,
+ thread_id: params.thread.id,
+ });
+ };
+ }
+
+ channelConfigState?.partialNext({
+ requestHandlers:
+ Object.keys(nextRequestHandlers).length > 0 ? nextRequestHandlers : undefined,
+ });
+ threadConfigState?.partialNext({
+ requestHandlers:
+ Object.keys(nextThreadRequestHandlers).length > 0
+ ? nextThreadRequestHandlers
+ : undefined,
+ });
+ }, [
+ doDeleteMessageRequest,
+ doMarkReadRequest,
+ doSendMessageRequest,
+ doUpdateMessageRequest,
+ threadInstance,
+ ]);
+};
diff --git a/src/components/Thread/index.ts b/src/components/Thread/index.ts
index 25fa274b85..bbce0a48d7 100644
--- a/src/components/Thread/index.ts
+++ b/src/components/Thread/index.ts
@@ -1,4 +1,5 @@
export * from './Thread';
+export * from './ThreadSlot';
export * from './ThreadHeader';
export { ThreadStart } from './ThreadStart';
export { useLegacyThreadContext } from './LegacyThreadContext';
diff --git a/src/components/Threads/ThreadContext.tsx b/src/components/Threads/ThreadContext.tsx
index af11beb563..8183cc14c8 100644
--- a/src/components/Threads/ThreadContext.tsx
+++ b/src/components/Threads/ThreadContext.tsx
@@ -1,7 +1,5 @@
import React, { createContext, useContext } from 'react';
-import { Channel } from '../Channel';
-
import type { PropsWithChildren } from 'react';
import type { Thread } from 'stream-chat';
@@ -15,7 +13,5 @@ export const ThreadProvider = ({
children,
thread,
}: PropsWithChildren<{ thread?: Thread }>) => (
-
- {children}
-
+ {children}
);
diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx
index 7398753d2c..8912dad81e 100644
--- a/src/components/Threads/ThreadList/ThreadList.tsx
+++ b/src/components/Threads/ThreadList/ThreadList.tsx
@@ -32,6 +32,21 @@ export const useThreadList = () => {
const { client } = useChatContext();
useEffect(() => {
+ // Reset derived pagination inputs before initial reload so the first mount requests
+ // the default first page size, rather than a limit inferred from cached/unseen threads.
+ const { pagination } = client.threads.state.getLatestValue();
+ client.threads.state.partialNext({
+ isThreadOrderStale: false,
+ pagination: {
+ ...pagination,
+ nextCursor: null,
+ },
+ ready: false,
+ threads: [],
+ unseenThreadIds: [],
+ });
+ void client.threads.reload({ force: true });
+
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
client.threads.activate();
diff --git a/src/components/Threads/ThreadList/ThreadListHeader.tsx b/src/components/Threads/ThreadList/ThreadListHeader.tsx
index 5b3047e8b2..c8b22dce3d 100644
--- a/src/components/Threads/ThreadList/ThreadListHeader.tsx
+++ b/src/components/Threads/ThreadList/ThreadListHeader.tsx
@@ -1,15 +1,16 @@
import React from 'react';
import { useComponentContext, useTranslationContext } from '../../../context';
-import { useThreadsViewContext } from '../../ChatView';
+import { useSlotThreads } from '../../ChatView';
export const ThreadListHeader = () => {
const { t } = useTranslationContext();
const { HeaderEndContent } = useComponentContext();
- const { activeThread } = useThreadsViewContext();
+ // A thread is "active" when one is bound in a thread slot.
+ const hasActiveThread = useSlotThreads().length > 0;
return (
{t('Threads')}
- {activeThread && HeaderEndContent &&
}
+ {hasActiveThread && HeaderEndContent &&
}
);
};
diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx
index a1c53db6a9..1cea2622f2 100644
--- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx
+++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx
@@ -1,14 +1,15 @@
+import type { ComponentPropsWithoutRef } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import clsx from 'clsx';
import type { ThreadState } from 'stream-chat';
-import type { ComponentPropsWithoutRef } from 'react';
import { Timestamp } from '../../Message/Timestamp';
import { Avatar, type AvatarProps, AvatarStack } from '../../Avatar';
import { useChannelPreviewInfo } from '../../ChannelListItem';
import { useChatContext, useTranslationContext } from '../../../context';
-import { useThreadsViewContext } from '../../ChatView';
+import { getChatViewEntityBinding, useChatViewNavigation } from '../../ChatView';
+import { useLayoutViewState } from '../../ChatView/hooks/useLayoutViewState';
import { useThreadListItemContext } from './ThreadListItem';
import { useStateStore } from '../../../store';
import { Badge } from '../../Badge';
@@ -22,6 +23,7 @@ export const ThreadListItemUI = ({
resetHighlighting,
...props
}: ThreadListItemUIProps) => {
+ const { onClick: onClickFromProps, ...buttonProps } = props;
const { client } = useChatContext();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const thread = useThreadListItemContext()!;
@@ -52,8 +54,12 @@ export const ThreadListItemUI = ({
const { displayTitle: channelDisplayTitle } = useChannelPreviewInfo({ channel });
const { t } = useTranslationContext('ThreadListItemUI');
-
- const { activeThread, setActiveThread } = useThreadsViewContext();
+ const { open } = useChatViewNavigation();
+ const { availableSlots, slotBindings } = useLayoutViewState();
+ const isSelected = availableSlots.some((slot) => {
+ const binding = getChatViewEntityBinding(slotBindings[slot]);
+ return binding?.kind === 'thread' && binding.source.id === thread.id;
+ });
const avatarProps: Partial | undefined = deletedAt
? undefined
@@ -88,15 +94,22 @@ export const ThreadListItemUI = ({
return (
setActiveThread(thread)}
+ onClick={(event) => {
+ const slot = event.ctrlKey || event.metaKey ? 'optional-thread' : 'main-thread';
+ void open(
+ { key: thread.id ?? undefined, kind: 'thread', source: thread },
+ { slot },
+ );
+ onClickFromProps?.(event);
+ }}
role='option'
- {...props}
+ {...buttonProps}
>
diff --git a/src/components/Threads/ThreadList/ThreadListSlot.tsx b/src/components/Threads/ThreadList/ThreadListSlot.tsx
new file mode 100644
index 0000000000..e7d1cb2067
--- /dev/null
+++ b/src/components/Threads/ThreadList/ThreadListSlot.tsx
@@ -0,0 +1,64 @@
+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 { SlotName } from '../../ChatView/layoutController/layoutControllerTypes';
+
+export type ThreadListSlotProps = PropsWithChildren<{
+ fallback?: ReactNode;
+ slot?: SlotName;
+}>;
+
+const LIST_BINDING_KEY = 'thread-list';
+const LIST_ENTITY_KIND = 'threadList';
+
+export const ThreadListSlot = ({
+ children,
+ fallback = null,
+ slot,
+}: ThreadListSlotProps) => {
+ const { 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;
+
+ if (requestedSlot && existingListSlot && existingListSlot !== requestedSlot) {
+ layoutController.release(existingListSlot);
+ }
+
+ const existingEntity = getChatViewEntityBinding(slotBindings[listSlot]);
+ if (existingEntity?.kind === LIST_ENTITY_KIND) {
+ return;
+ }
+
+ layoutController.bind(
+ listSlot,
+ createChatViewSlotBinding({
+ key: LIST_BINDING_KEY,
+ kind: LIST_ENTITY_KIND,
+ source: {},
+ }),
+ );
+ }, [existingListSlot, layoutController, listSlot, requestedSlot, slotBindings]);
+
+ if (!listSlot) return <>{fallback}>;
+
+ return
{children ?? fallback};
+};
diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx
index 693d4f2399..aefe0ae973 100644
--- a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx
+++ b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx
@@ -1,18 +1,17 @@
import React from 'react';
import { cleanup, render, screen } from '@testing-library/react';
-import { fromPartial } from '@total-typescript/shoehorn';
-import type { Thread } from 'stream-chat';
-import { ChatProvider, WithComponents } from '../../../../context';
+import { WithComponents } from '../../../../context';
import { TranslationProvider } from '../../../../context/TranslationContext';
-import { mockChatContext, mockTranslationContextValue } from '../../../../mock-builders';
+import { mockTranslationContextValue } from '../../../../mock-builders';
import { ThreadListHeader } from '../ThreadListHeader';
+// The header derives "a thread is active" from the ChatView slot bindings
+// (useSlotThreads), not a ThreadsViewContext. Mock it to control activeness.
+const mockUseSlotThreads = vi.fn();
vi.mock('../../../ChatView', () => ({
- useThreadsViewContext: vi.fn(() => ({ activeThread: undefined })),
+ useSlotThreads: () => mockUseSlotThreads(),
}));
-import { useThreadsViewContext } from '../../../ChatView';
-
const t = vi.fn((key: string) => key);
const HeaderEndContent = () =>
;
@@ -20,30 +19,23 @@ afterEach(cleanup);
describe('ThreadListHeader', () => {
it('should not render HeaderEndContent when not provided via ComponentContext', () => {
+ mockUseSlotThreads.mockReturnValue([{ slot: 'main-thread', thread: {} }]);
render(
-
-
-
-
- ,
+
+
+ ,
);
expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument();
});
it('should render HeaderEndContent when a thread is active', () => {
- vi.mocked(useThreadsViewContext).mockReturnValue({
- activeThread: fromPartial
({}),
- setActiveThread: vi.fn(),
- });
-
+ mockUseSlotThreads.mockReturnValue([{ slot: 'main-thread', thread: {} }]);
render(
-
-
-
-
-
+
+
+
,
);
@@ -51,18 +43,12 @@ describe('ThreadListHeader', () => {
});
it('should not render HeaderEndContent when no thread is active', () => {
- vi.mocked(useThreadsViewContext).mockReturnValue({
- activeThread: undefined,
- setActiveThread: vi.fn(),
- });
-
+ mockUseSlotThreads.mockReturnValue([]);
render(
-
-
-
-
-
+
+
+
,
);
diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx
index 7d683f4f41..11c6130450 100644
--- a/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx
+++ b/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx
@@ -7,7 +7,8 @@ import { ThreadListItemUI } from '../ThreadListItemUI';
const mockUseChatContext = vi.fn();
const mockUseTranslationContext = vi.fn();
const mockUseStateStore = vi.fn();
-const mockUseThreadsViewContext = vi.fn();
+const mockUseLayoutViewState = vi.fn();
+const mockOpen = vi.fn();
const mockUseThreadListItemContext = vi.fn();
const mockUseChannelPreviewInfo = vi.fn();
@@ -20,8 +21,15 @@ vi.mock('../../../../store', () => ({
useStateStore: (...args) => mockUseStateStore(...args),
}));
+// Active state now comes from the ChatView slot bindings (a thread bound in a slot),
+// not a ThreadsViewContext; selection opens the thread into a slot via `open`.
vi.mock('../../../ChatView', () => ({
- useThreadsViewContext: () => mockUseThreadsViewContext(),
+ getChatViewEntityBinding: (binding) => binding,
+ useChatViewNavigation: () => ({ open: mockOpen }),
+}));
+
+vi.mock('../../../ChatView/hooks/useLayoutViewState', () => ({
+ useLayoutViewState: () => mockUseLayoutViewState(),
}));
vi.mock('../ThreadListItem', () => ({
@@ -49,6 +57,12 @@ vi.mock('../../../SummarizedMessagePreview', () => ({
SummarizedMessagePreview: () => ,
}));
+// Bind (or not) `thread-1` into the `main-thread` slot to drive the active state.
+const bindThread = (id?: string) => ({
+ availableSlots: id ? ['main-thread'] : [],
+ slotBindings: id ? { 'main-thread': { kind: 'thread', source: { id } } } : {},
+});
+
describe('ThreadListItemUI', () => {
const thread = { id: 'thread-1', state: {} };
@@ -76,22 +90,16 @@ describe('ThreadListItemUI', () => {
vi.clearAllMocks();
});
- it('uses aria-selected=true when the thread is active', () => {
- mockUseThreadsViewContext.mockReturnValue({
- activeThread: thread,
- setActiveThread: vi.fn(),
- });
+ it('marks the item selected when the thread is bound in a slot', () => {
+ mockUseLayoutViewState.mockReturnValue(bindThread('thread-1'));
render();
expect(screen.getByRole('option')).toHaveAttribute('aria-selected', 'true');
});
- it('uses aria-selected=false when the thread is not active', () => {
- mockUseThreadsViewContext.mockReturnValue({
- activeThread: { id: 'thread-2' },
- setActiveThread: vi.fn(),
- });
+ it('marks the item not selected when a different thread is bound', () => {
+ mockUseLayoutViewState.mockReturnValue(bindThread('thread-2'));
render();
@@ -99,10 +107,7 @@ describe('ThreadListItemUI', () => {
});
it('passes axe checks in listbox context', async () => {
- mockUseThreadsViewContext.mockReturnValue({
- activeThread: thread,
- setActiveThread: vi.fn(),
- });
+ mockUseLayoutViewState.mockReturnValue(bindThread('thread-1'));
const { container } = render(
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/Threads/ThreadList/styling/ThreadListItemUI.scss b/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss
index 2bf96fff5f..969f705ff2 100644
--- a/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss
+++ b/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss
@@ -31,7 +31,7 @@
&:not(:disabled):active {
background: var(--str-chat__background-utility-pressed);
}
- &:not(:disabled)[aria-pressed='true'] {
+ &:not(:disabled)[aria-selected='true'] {
background: var(--str-chat__background-utility-selected);
}
diff --git a/src/components/TypingIndicator/TypingIndicator.tsx b/src/components/TypingIndicator/TypingIndicator.tsx
index f310945b63..eb68f9af90 100644
--- a/src/components/TypingIndicator/TypingIndicator.tsx
+++ b/src/components/TypingIndicator/TypingIndicator.tsx
@@ -1,121 +1,106 @@
-import React, { useEffect, useMemo } from 'react';
+import React from 'react';
import clsx from 'clsx';
+import type { TextComposerState, ThreadState } from 'stream-chat';
-import { AvatarStack } from '../Avatar';
-import { TypingIndicatorDots } from './TypingIndicatorDots';
-import { useChannelStateContext } from '../../context/ChannelStateContext';
import { useChatContext } from '../../context/ChatContext';
import { useTranslationContext } from '../../context/TranslationContext';
-import { useTypingContext } from '../../context/TypingContext';
-import { useThreadContext } from '../Threads';
-import { VisuallyHidden } from '../VisuallyHidden';
-
-import { useDebouncedTypingActive } from './hooks/useDebouncedTypingActive';
-import { getTypingStatusMessage } from './utils/getTypingStatusMessage';
-
-export type TypingIndicatorProps = {
- /** When false, the indicator is not rendered (e.g. when list is not scrolled to bottom). Omit or true to show when typing. */
- isMessageListScrolledToBottom?: boolean;
- scrollToBottom: () => void;
- /** Whether the typing indicator is in a thread */
- threadList?: boolean;
+import { useThreadContext } from '../Threads/ThreadContext';
+import { useStateStore } from '../../store';
+import { useChannelConfig } from '../Channel/hooks/useChannelConfig';
+import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
+
+const threadParentMessageSelector = ({ parentMessage }: ThreadState) => ({
+ parentMessage,
+});
+
+const textComposerTypingSelector = ({ typing }: TextComposerState) => ({ typing });
+
+const useJoinTypingUsers = (names: string[]) => {
+ const { t } = useTranslationContext();
+
+ if (!names.length) return null;
+
+ const [name, ...rest] = names;
+
+ if (names.length === 1)
+ return t('{{ user }} is typing...', {
+ user: name,
+ });
+
+ const MAX_JOINED_USERS = 3;
+
+ if (names.length > MAX_JOINED_USERS)
+ return t('{{ users }} and more are typing...', {
+ users: names.slice(0, MAX_JOINED_USERS).join(', ').trim(),
+ });
+
+ return t('{{ users }} and {{ user }} are typing...', {
+ user: name,
+ users: rest.join(', ').trim(),
+ });
};
+// MERGE-RECONCILE: took PR #2909's no-props TypingIndicator (the merged MessageList
+// renders
). Master's richer version (isMessageListScrolledToBottom/
+// scrollToBottom/threadList props, useDebouncedTypingActive, AvatarStack of typing users,
+// a11y typingAnnouncement via VisuallyHidden) was NOT carried over. Reconcile if those
+// features are desired.
/**
- * TypingIndicator shows avatars of users currently typing and a bubble with animated dots.
- * Renders only for other participants (never the current user), only when scrolled to latest message if isMessageListScrolledToBottom is provided.
- * It must be a child of Channel component.
+ * TypingIndicator lists users currently typing, it needs to be a child of Channel component
*/
-const UnMemoizedTypingIndicator = (props: TypingIndicatorProps) => {
- const { isMessageListScrolledToBottom = true, scrollToBottom, threadList } = props;
-
- const { channelConfig, thread } = useChannelStateContext('TypingIndicator');
- const threadInstance = useThreadContext();
- const parentId = threadInstance?.id ?? thread?.id;
+export const TypingIndicator = () => {
+ const messageComposer = useMessageComposerController();
+ const channelConfig = useChannelConfig({ cid: messageComposer.channel.cid });
const { client } = useChatContext('TypingIndicator');
- const { t } = useTranslationContext();
- const { typing = {} } = useTypingContext('TypingIndicator');
+ const { typing = {} } =
+ useStateStore(messageComposer.textComposer?.state, textComposerTypingSelector) ?? {};
+ const thread = useThreadContext();
+ const isThreadList = !!thread;
+ const { parentMessage } =
+ useStateStore(thread?.state, threadParentMessageSelector) ?? {};
- const typingInChannel = !threadList
+ 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 === parentId,
+ ({ parent_id, user }) =>
+ user?.id !== client.user?.id && parent_id === parentMessage?.id,
)
: [];
- const typingUsers = threadList ? typingInThread : typingInChannel;
- const { displayUsers } = useDebouncedTypingActive(typingUsers);
- const showIndicator = displayUsers.length > 0;
- const typingAnnouncement = useMemo(
- () => getTypingStatusMessage(displayUsers, t),
- [displayUsers, t],
- );
+ const typingUserList = (isThreadList ? typingInThread : typingInChannel)
+ .map(({ user }) => user?.name || user?.id)
+ .filter(Boolean) as string[];
- const displayInfo = useMemo(
- () =>
- displayUsers.map(
- ({ user }) =>
- ({
- id: user?.id,
- imageUrl: user?.image,
- userName: user?.name,
- }) as const,
- ),
- [displayUsers],
- );
+ const joinedTypingUsers = useJoinTypingUsers(typingUserList);
- useEffect(() => {
- if (showIndicator && isMessageListScrolledToBottom) scrollToBottom();
- }, [scrollToBottom, isMessageListScrolledToBottom, showIndicator]);
+ const isTypingActive =
+ (isThreadList && typingInThread.length) || (!isThreadList && typingInChannel.length);
if (channelConfig?.typing_events === false) {
return null;
}
- if (!showIndicator || !isMessageListScrolledToBottom) {
- return null;
- }
-
+ if (!isTypingActive) return null;
return (
- {displayInfo.length > 0 && (
-
- )}
-
-
-
-
+
+
+
+
+
+
+ {joinedTypingUsers}
-
-
- {typingAnnouncement}
-
-
);
};
-
-export const TypingIndicator = React.memo(
- UnMemoizedTypingIndicator,
-) as typeof UnMemoizedTypingIndicator;
diff --git a/src/components/TypingIndicator/TypingIndicatorHeader.tsx b/src/components/TypingIndicator/TypingIndicatorHeader.tsx
index b29761d19a..50396f36ae 100644
--- a/src/components/TypingIndicator/TypingIndicatorHeader.tsx
+++ b/src/components/TypingIndicator/TypingIndicatorHeader.tsx
@@ -2,15 +2,20 @@ import React from 'react';
import clsx from 'clsx';
import { TypingIndicatorDots } from './TypingIndicatorDots';
-import { useChannelStateContext } from '../../context/ChannelStateContext';
+import { useChannel } from '../../context';
import { useChatContext } from '../../context/ChatContext';
import { useTranslationContext } from '../../context/TranslationContext';
-import { useTypingContext } from '../../context/TypingContext';
+import { useChannelConfig } from '../Channel/hooks/useChannelConfig';
+import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
+import { useStateStore } from '../../store';
import { useThreadContext } from '../Threads';
+import type { TextComposerState } from 'stream-chat';
import { useDebouncedTypingActive } from './hooks/useDebouncedTypingActive';
import { getTypingStatusMessage } from './utils/getTypingStatusMessage';
+const textComposerTypingSelector = ({ typing }: TextComposerState) => ({ typing });
+
export type TypingIndicatorHeaderProps = {
/** When true, show typing in the current thread only; when false, show typing in the channel. */
threadList?: boolean;
@@ -24,11 +29,14 @@ export const TypingIndicatorHeader = (props: TypingIndicatorHeaderProps) => {
const { threadList = false } = props;
const { t } = useTranslationContext();
- const { channelConfig, thread } = useChannelStateContext('TypingIndicatorHeader');
+ const channel = useChannel();
+ const channelConfig = useChannelConfig({ cid: channel.cid });
const threadInstance = useThreadContext();
- const parentId = threadInstance?.id ?? thread?.id;
+ const parentId = threadInstance?.id;
const { client } = useChatContext('TypingIndicatorHeader');
- const { typing = {} } = useTypingContext('TypingIndicatorHeader');
+ const messageComposer = useMessageComposerController();
+ const { typing = {} } =
+ useStateStore(messageComposer.textComposer?.state, textComposerTypingSelector) ?? {};
const typingInChannel = !threadList
? Object.values(typing).filter(
diff --git a/src/components/TypingIndicator/__tests__/TypingIndicator.test.js b/src/components/TypingIndicator/__tests__/TypingIndicator.test.js
new file mode 100644
index 0000000000..f2c4ae87fd
--- /dev/null
+++ b/src/components/TypingIndicator/__tests__/TypingIndicator.test.js
@@ -0,0 +1,248 @@
+import React from 'react';
+
+import { cleanup, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { toHaveNoViolations } from 'jest-axe';
+import { axe } from '../../../../axe-helper';
+import { TypingIndicator } from '../TypingIndicator';
+
+import { ChannelInstanceProvider } from '../../../context/ChannelInstanceContext';
+import { ChatProvider } from '../../../context/ChatContext';
+import { ComponentProvider } from '../../../context/ComponentContext';
+import { ThreadProvider } from '../../Threads/ThreadContext';
+
+import {
+ generateChannel,
+ generateUser,
+ getOrCreateChannelApi,
+ initClientWithChannels,
+ useMockedApis,
+} from '../../../mock-builders';
+
+expect.extend(toHaveNoViolations);
+
+const me = generateUser();
+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(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe('TypingIndicator', () => {
+ afterEach(cleanup);
+
+ it('should throw without proper context values', () => {
+ expect(() =>
+ render(
+
+
+
+
+ ,
+ ),
+ ).toThrow('The useChannel hook could not resolve a channel.');
+ });
+
+ it('should render hidden indicator with empty typing', async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels();
+ channel.messageComposer.textComposer.typing = {};
+ const { container } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("should not render TypingIndicator when it's just you typing", async () => {
+ const { container } = await renderComponent({ alice: { user: me } });
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should render TypingIndicator when someone else is typing', async () => {
+ const { container } = await renderComponent({
+ jessica: { user: { id: 'jessica', image: 'jessica.jpg' } },
+ });
+
+ expect(container.firstChild).toHaveClass('str-chat__typing-indicator--typing');
+ expect(screen.getByText('{{ user }} is typing...')).toBeInTheDocument();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should render TypingIndicator when you and someone else are typing', async () => {
+ const otherUser = { user: { id: 'jessica', image: 'jessica.jpg' } };
+ const { container } = await renderComponent({
+ alice: { user: me },
+ jessica: otherUser,
+ });
+
+ expect(container.firstChild).toHaveClass('str-chat__typing-indicator--typing');
+ expect(screen.getByText('{{ user }} is typing...')).toBeInTheDocument();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should render TypingIndicator when multiple users are typing', async () => {
+ const { container } = await renderComponent({
+ alice: { user: me },
+ jessica: { user: { id: 'jessica', image: 'jessica.jpg' } },
+ joris: { user: { id: 'joris', image: 'joris.jpg' } },
+ margriet: { user: { id: 'margriet', image: 'margriet.jpg' } },
+ });
+ expect(
+ screen.getByText('{{ users }} and {{ user }} are typing...'),
+ ).toBeInTheDocument();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should render TypingIndicator when larger amount of users are typing', async () => {
+ const { container } = await renderComponent({
+ alice: { user: me },
+ axel: { user: { id: 'axel', image: 'axel.jpg' } },
+ jessica: { user: { id: 'jessica', image: 'jessica.jpg' } },
+ joris: { user: { id: 'joris', image: 'joris.jpg' } },
+ margriet: { user: { id: 'margriet', image: 'margriet.jpg' } },
+ });
+ expect(screen.getByText('{{ users }} and more are typing...')).toBeInTheDocument();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should render null if typing_events is disabled', async () => {
+ 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(
+
+
+
+
+
+
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ describe('TypingIndicator in thread', () => {
+ let client;
+ let ch;
+ let channel;
+ const parent_id = 'sample-thread';
+ const otherUserId = 'test-user';
+
+ beforeEach(async () => {
+ const setup = await initClientWithChannels();
+ client = setup.client;
+ ch = generateChannel({ config: { typing_events: true } });
+ useMockedApis(client, [getOrCreateChannelApi(ch)]);
+ channel = client.channel('messaging', ch.id);
+ await channel.watch();
+ });
+
+ afterEach(cleanup);
+
+ it('should render TypingIndicator if user is typing in thread', async () => {
+ const { container } = await renderComponent(
+ { [otherUserId]: { parent_id, user: otherUserId } },
+ {
+ channel,
+ client,
+ },
+ parent_id,
+ );
+
+ expect(container.firstChild).toHaveClass('str-chat__typing-indicator--typing');
+ });
+
+ it('should not render TypingIndicator in main channel if user is typing in thread', async () => {
+ const { container } = await renderComponent(
+ { [otherUserId]: { parent_id, user: otherUserId } },
+ {
+ channel,
+ client,
+ },
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render TypingIndicator in thread if user is typing in main channel', async () => {
+ const { container } = await renderComponent(
+ { [otherUserId]: { user: otherUserId } },
+ {
+ channel,
+ client,
+ },
+ parent_id,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ 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 } },
+ {
+ channel,
+ client,
+ },
+ parent_id,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+ });
+});
diff --git a/src/components/Window/Window.tsx b/src/components/Window/Window.tsx
deleted file mode 100644
index e8b0ffd786..0000000000
--- a/src/components/Window/Window.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { PropsWithChildren } from 'react';
-import React from 'react';
-import clsx from 'clsx';
-
-import type { LocalMessage } from 'stream-chat';
-import { useChannelStateContext } from '../../context/ChannelStateContext';
-
-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}
-
- );
-};
-
-/**
- * A UI component for conditionally displaying a Thread or Channel
- */
-export const Window = React.memo(UnMemoizedWindow) as typeof UnMemoizedWindow;
diff --git a/src/components/Window/__tests__/Window.test.tsx b/src/components/Window/__tests__/Window.test.tsx
deleted file mode 100644
index 9cce72087b..0000000000
--- a/src/components/Window/__tests__/Window.test.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import React from 'react';
-import { render } from '@testing-library/react';
-
-import { Window } from '../Window';
-
-import { ChannelStateProvider } from '../../../context/ChannelStateContext';
-import { generateMessage, mockChannelStateContext } from '../../../mock-builders';
-
-const renderComponent = ({ channelStateContextMock, props }: any) =>
- render(
-
-
- ,
- );
-
-const thread = generateMessage();
-const THREAD_OPEN_CLASS_NAME = 'str-chat__main-panel--thread-open';
-
-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);
- }
- },
- );
-});
diff --git a/src/components/Window/index.ts b/src/components/Window/index.ts
deleted file mode 100644
index c88c3a3766..0000000000
--- a/src/components/Window/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './Window';
diff --git a/src/components/index.ts b/src/components/index.ts
index cc00e2b542..b291945410 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -44,7 +44,6 @@ export * from './Threads';
export * from './Tooltip';
export * from './TypingIndicator';
export * from './VisuallyHidden';
-export * from './Window';
export { FileInput } from './ReactFileUtilities';
export type { FileInputProps } from './ReactFileUtilities';
diff --git a/src/context/AttachmentContext.tsx b/src/context/AttachmentContext.tsx
new file mode 100644
index 0000000000..75dcf48d0a
--- /dev/null
+++ b/src/context/AttachmentContext.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import type { GiphyVersions } from 'stream-chat';
+import type {
+ ImageAttachmentSizeHandler,
+ VideoAttachmentSizeHandler,
+} from '../components/Attachment/Attachment';
+import {
+ getImageAttachmentConfiguration,
+ getVideoAttachmentConfiguration,
+} from '../components/Attachment/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/context/ChannelActionContext.tsx b/src/context/ChannelActionContext.tsx
deleted file mode 100644
index fd668fa7f1..0000000000
--- a/src/context/ChannelActionContext.tsx
+++ /dev/null
@@ -1,98 +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 = {
- 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/ChannelListContext.tsx b/src/context/ChannelListContext.tsx
index 8cba1706fe..d0aa623c50 100644
--- a/src/context/ChannelListContext.tsx
+++ b/src/context/ChannelListContext.tsx
@@ -1,26 +1,16 @@
-import type { Dispatch, PropsWithChildren, SetStateAction } from 'react';
+import type { PropsWithChildren } from 'react';
import React, { createContext, useContext } from 'react';
-import type { Channel } from 'stream-chat';
+import type { ChannelPaginator } from 'stream-chat';
export type ChannelListContextValue = {
/**
- * State representing the array of loaded channels.
- * Channels query is executed by default only by ChannelList component in the SDK.
+ * The primary channel paginator held by the `ChannelPaginatorsOrchestrator` on `ChatContext`.
+ * Read the loaded channels reactively with `useStateStore(paginator.state, …)`, load the next
+ * page with `paginator.next()`, and mutate the loaded list (e.g. prepend a just-opened channel)
+ * via `paginator.setItems({ valueOrFactory })`. Undefined when rendered outside a channel list.
*/
- channels: Channel[];
- /**
- * Indicator for channel pagination to determine whether more items can be loaded
- */
- hasNextPage: boolean;
- /**
- * Pagination function to load more channels
- */
- loadNextPage(): Promise;
- /**
- * Sets the list of Channel objects to be rendered by ChannelList component.
- */
- setChannels: Dispatch>;
+ paginator?: ChannelPaginator;
};
export const ChannelListContext = createContext(
@@ -28,7 +18,8 @@ export const ChannelListContext = createContext) => (
-
- {children}
-
+ {children}
);
-export const useChannelListContext = () => {
- const contextValue = useContext(ChannelListContext);
-
- if (!contextValue) {
- return {} as ChannelListContextValue;
- }
-
- return contextValue as unknown as ChannelListContextValue;
-};
+export const useChannelListContext = (): ChannelListContextValue =>
+ useContext(ChannelListContext) ?? {};
diff --git a/src/context/ChannelStateContext.tsx b/src/context/ChannelStateContext.tsx
deleted file mode 100644
index 3aeb8ab128..0000000000
--- a/src/context/ChannelStateContext.tsx
+++ /dev/null
@@ -1,94 +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;
- loadingMoreForJumpToChannelMessage?: 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}
-
-);
-
-let remainingWarningCount = 1;
-
-export const useChannelStateContext = (componentName?: string) => {
- const contextValue = useContext(ChannelStateContext);
-
- if (!contextValue) {
- if (componentName && remainingWarningCount > 0) {
- 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.`,
- );
- remainingWarningCount -= 1;
- }
-
- return {} as ChannelStateContextValue;
- }
-
- return contextValue as unknown as ChannelStateContextValue;
-};
diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx
index 83af30a3de..494bf0c0d0 100644
--- a/src/context/ChatContext.tsx
+++ b/src/context/ChatContext.tsx
@@ -2,13 +2,12 @@ import React, { useContext } from 'react';
import type { PropsWithChildren } from 'react';
import type {
AppSettingsAPIResponse,
- Channel,
+ ChannelPaginatorsOrchestrator,
Mute,
SearchController,
} from 'stream-chat';
import type { ChatProps } from '../components/Chat/Chat';
-import type { ChannelsQueryState } from '../components/Chat/hooks/useChannelsQueryState';
type CSSClasses =
| 'chat'
@@ -28,30 +27,16 @@ type ChannelConfId = string; // e.g.: "messaging:general"
export type ChatContextValue = {
/**
- * Indicates, whether a channels query has been triggered within ChannelList by its channels pagination controller.
+ * `ChannelPaginatorsOrchestrator` used to query and manage channels across one or
+ * more channel lists (the channel-list data source + cross-list ownership).
*/
- channelsQueryState: ChannelsQueryState;
+ channelPaginatorsOrchestrator: ChannelPaginatorsOrchestrator;
getAppSettings: () => Promise | null;
latestMessageDatesByChannels: Record;
mutes: Array;
/** 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 787f76c6e0..f492842b75 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -1,4 +1,4 @@
-import type { ComponentProps, PropsWithChildren } from 'react';
+import type { ComponentProps, ComponentType, PropsWithChildren } from 'react';
import React, { useContext } from 'react';
import {
@@ -9,7 +9,6 @@ import {
type CalloutDialogProps,
type ChannelAvatarProps,
type ChannelListItemUIProps,
- type ChannelListUIProps,
type ContextMenuContentProps,
type ContextMenuProps,
type DateSeparatorProps,
@@ -60,7 +59,6 @@ import {
type ThreadListItemUIProps,
type TimestampProps,
type TranslationIndicatorProps,
- type TypingIndicatorProps,
type UnreadMessagesNotificationProps,
type UnreadMessagesSeparatorProps,
type VoiceRecordingPreviewSlotProps,
@@ -83,6 +81,19 @@ import type { UploadedSizeIndicatorProps } from '../components/Loading/UploadedS
import type { NotificationAnnouncerProps } from '../components/Accessibility';
export type ComponentContextValue = {
+ /** Custom UI component rendered when a paginated list (e.g. the channel list) is empty. */
+ EmptyListIndicator?: React.ComponentType;
+ /** Custom UI component rendered at the top/bottom edge of a paginated list. */
+ EndReachedIndicator?: React.ComponentType<{
+ hasEnded: boolean;
+ reached: 'top' | 'bottom';
+ }>;
+ /** Custom UI component rendered while a paginated list loads its first page. */
+ FirstPageLoadingIndicator?: React.ComponentType;
+ /** Custom UI component rendered per item of a paginated list. */
+ ListItem?: ComponentType<{ item: unknown }>;
+ /** Custom UI component rendered while a paginated list loads its next page. */
+ LoadingNextPageIndicator?: React.ComponentType<{ isLoading?: boolean }>;
/** Custom UI component to display additional message composer action buttons left to the textarea, defaults to and accepts same props as: [AdditionalMessageComposerActions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageComposer/MessageComposerActions.tsx) */
AdditionalMessageComposerActions?: React.ComponentType;
/** Custom UI component to display a message attachment, defaults to and accepts same props as: [Attachment](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/Attachment.tsx) */
@@ -113,8 +124,6 @@ export type ComponentContextValue = {
CalloutDialog?: React.ComponentType;
/** Custom UI component shown instead of the image when it fails to load, defaults to and accepts same props as: [ImagePlaceholder](https://github.com/GetStream/stream-chat-react/blob/master/src/components/BaseImage/ImagePlaceholder.tsx) */
ImagePlaceholder?: React.ComponentType;
- /** Custom UI component to display the container for the queried channels, defaults to and accepts same props as: [ChannelListUI](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelList/ChannelListUI.tsx) */
- ChannelListUI?: React.ComponentType;
/** Custom UI component to display set of action buttons within `ChannelListItemUI` component, accepts same props as: [ChannelListItemActionButtons](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelList/ChannelListItemActionButtons.tsx) */
ChannelListItemActionButtons?: React.ComponentType;
/** Custom UI component to display the channel preview in the list, defaults to and accepts same props as: [ChannelListItemUI](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelPreview/ChannelListItemUI.tsx) */
@@ -284,7 +293,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 rendered at the end of sidebar headers (ChannelListHeader, ThreadListHeader). No default — if omitted, the slot is empty. */
diff --git a/src/context/MessageBounceContext.tsx b/src/context/MessageBounceContext.tsx
index 5e64dc67f2..02885f0175 100644
--- a/src/context/MessageBounceContext.tsx
+++ b/src/context/MessageBounceContext.tsx
@@ -1,8 +1,12 @@
import type { ReactEventHandler } from 'react';
import React, { createContext, useCallback, useContext, useMemo } from 'react';
import { useMessageContext } from './MessageContext';
-import { useChannelActionContext } from './ChannelActionContext';
-import { isMessageBounced, useMessageComposerController } from '../components';
+import { useChannel } from './useChannel';
+import {
+ isMessageBounced,
+ useMessageComposerController,
+ useRetryHandler,
+} from '../components';
import { savePreEditSnapshot } from '../components/MessageComposer/preEditSnapshot';
import type { LocalMessage } from 'stream-chat';
import type { PropsWithChildrenOnly } from '../types/types';
@@ -34,9 +38,8 @@ export function useMessageBounceContext(componentName?: string) {
export function MessageBounceProvider({ children }: PropsWithChildrenOnly) {
const messageComposer = useMessageComposerController();
- const { handleRetry: doHandleRetry, message } = useMessageContext(
- 'MessageBounceProvider',
- );
+ const { message } = useMessageContext('MessageBounceProvider');
+ const doHandleRetry = useRetryHandler();
if (!isMessageBounced(message)) {
console.warn(
@@ -44,11 +47,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) => {
@@ -59,8 +62,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 a16a8920ef..40280f10c2 100644
--- a/src/context/MessageContext.tsx
+++ b/src/context/MessageContext.tsx
@@ -4,14 +4,11 @@ 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 { ReactEventHandler } from '../components/Message/types';
import type { MessageActionsArray } from '../components/Message/utils';
@@ -43,7 +40,7 @@ export type MessageContextValue = {
handleMarkUnread: ReactEventHandler;
/** Function to mute a user in a Channel */
handleMute: ReactEventHandler;
- /** Function to open a Thread on a Message */
+ /** Function to open a thread for the message (routed through ChatView navigation) */
handleOpenThread: ReactEventHandler;
/** Function to pin a Message in a Channel */
handlePin: ReactEventHandler;
@@ -52,14 +49,12 @@ export type MessageContextValue = {
reactionType: string,
event: React.BaseSyntheticEvent,
) => Promise;
- /** Function to retry sending a Message */
- handleRetry: ChannelActionContextValue['retrySendMessage'];
+ /** Function to resend a failed message */
+ handleRetry: (message: LocalMessage) => Promise | void;
/** 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 */
@@ -98,8 +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[];
/** Sort options to provide to a reactions query */
reactionDetailsSort?: ReactionSort;
/** A list of users that have read this Message */
@@ -112,7 +105,7 @@ export type MessageContextValue = {
returnAllReadData?: boolean;
/** Comparator function to sort reactions, defaults to chronological order */
sortReactions?: ReactionsComparator;
- /** Whether or not the Message is in a Thread */
+ /** If true, the Message is rendered inside a thread's reply list */
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 29c56c9f2a..fd08f1b170 100644
--- a/src/context/index.ts
+++ b/src/context/index.ts
@@ -1,7 +1,7 @@
+export * from './AttachmentContext';
export * from './AttachmentSelectorContext';
-export * from './ChannelActionContext';
export * from './ChannelListContext';
-export * from './ChannelStateContext';
+export * from './ChannelInstanceContext';
export * from './ChatContext';
export * from './ComponentContext';
export * from './DialogManagerContext';
@@ -13,5 +13,5 @@ export * from './MessageTranslationViewContext';
export * from './ModalContext';
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/hooks/__tests__/useIsDmChannel.test.tsx b/src/hooks/__tests__/useIsDmChannel.test.tsx
new file mode 100644
index 0000000000..434e95e928
--- /dev/null
+++ b/src/hooks/__tests__/useIsDmChannel.test.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+import { StateStore } from 'stream-chat';
+import type { Channel, ChannelState, MembersState, StreamChat } from 'stream-chat';
+
+import { ChannelInstanceProvider, ChatProvider } from '../../context';
+import { mockChatContext } from '../../mock-builders';
+import { useIsDmChannel } from '../useIsDmChannel';
+
+// useIsDmChannel subscribes to channel.state.membersStore for reactivity and delegates the
+// actual check to the pure `isDmChannel` util (covered by its own test). These tests focus on
+// the hook wiring: it returns the isDmChannel result for the context channel and recomputes when
+// the membersStore emits.
+
+const makeChannel = (memberCount: number, members: ChannelState['members'] = {}) => {
+ const membersStore = new StateStore({ memberCount, members });
+ const channel = fromPartial({
+ data: { member_count: memberCount },
+ state: { members, membersStore },
+ });
+ return { channel, membersStore };
+};
+
+const renderForChannel = (channel: Channel) => {
+ const client = fromPartial({ user: { id: 'user-1' } });
+ const wrapper = ({ children }: React.PropsWithChildren) => (
+
+ {children}
+
+ );
+ return renderHook(() => useIsDmChannel(), { wrapper });
+};
+
+describe('useIsDmChannel', () => {
+ it('is true for a one-member channel', () => {
+ const { channel } = makeChannel(1);
+
+ const { result } = renderForChannel(channel);
+
+ expect(result.current).toBe(true);
+ });
+
+ it('is true for a two-member channel that includes the current user', () => {
+ const members = fromPartial({
+ 'user-1': { user: { id: 'user-1' } },
+ 'user-2': { user: { id: 'user-2' } },
+ });
+
+ const { result } = renderForChannel(makeChannel(2, members).channel);
+
+ expect(result.current).toBe(true);
+ });
+
+ it('is false for a two-member channel that does not include the current user', () => {
+ const members = fromPartial({
+ 'user-2': { user: { id: 'user-2' } },
+ 'user-3': { user: { id: 'user-3' } },
+ });
+
+ const { result } = renderForChannel(makeChannel(2, members).channel);
+
+ expect(result.current).toBe(false);
+ });
+
+ it('is false for a group channel', () => {
+ const { result } = renderForChannel(makeChannel(3).channel);
+
+ expect(result.current).toBe(false);
+ });
+
+ it('recomputes reactively when the membersStore emits', () => {
+ const { channel, membersStore } = makeChannel(3);
+
+ const { result } = renderForChannel(channel);
+ expect(result.current).toBe(false);
+
+ // The channel becomes a DM; the membersStore emission triggers the recompute.
+ if (channel.data) channel.data.member_count = 1;
+ act(() => membersStore.partialNext({ memberCount: 1, members: {} }));
+
+ expect(result.current).toBe(true);
+ });
+});
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000000..5e47ca0110
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useIsDmChannel';
+export * from './useMessagePaginator';
diff --git a/src/hooks/useIsDmChannel.ts b/src/hooks/useIsDmChannel.ts
new file mode 100644
index 0000000000..f5dd21cc9f
--- /dev/null
+++ b/src/hooks/useIsDmChannel.ts
@@ -0,0 +1,33 @@
+import type { Channel, MembersState } from 'stream-chat';
+
+import { useChannel, useChatContext } from '../context';
+import { useStateStore } from '../store';
+import { isDmChannel } from '../utils';
+
+export type UseIsDmChannelParams = {
+ channel?: Channel;
+};
+
+const membersSelector = (nextValue: MembersState) => ({
+ memberCount: nextValue.memberCount,
+ members: nextValue.members,
+});
+
+/**
+ * Reactively determines whether a channel is a direct-messaging channel.
+ *
+ * Subscribes to the channel's `membersStore` so the result recomputes when membership
+ * changes, then delegates the actual check to the `isDmChannel` util. Defaults to the
+ * channel from context; pass `channel` to check a specific one.
+ */
+export const useIsDmChannel = ({
+ channel: channelOverride,
+}: UseIsDmChannelParams = {}) => {
+ const contextChannel = useChannel();
+ const channel = channelOverride ?? contextChannel;
+ const { client } = useChatContext();
+ // Subscribe for reactivity — re-run isDmChannel whenever membership changes.
+ useStateStore(channel.state.membersStore, membersSelector);
+
+ return isDmChannel({ channel, ownUserId: client.user?.id });
+};
diff --git a/src/hooks/useMessagePaginator.ts b/src/hooks/useMessagePaginator.ts
new file mode 100644
index 0000000000..fcdcb8b5fc
--- /dev/null
+++ b/src/hooks/useMessagePaginator.ts
@@ -0,0 +1,13 @@
+import type { MessagePaginator } from 'stream-chat';
+import { useChannel } from '../context';
+import { useThreadContext } from '../components';
+
+/**
+ * Hook can be used only in children of and components.
+ */
+export const useMessagePaginator = (): MessagePaginator => {
+ const channel = useChannel();
+ const thread = useThreadContext();
+
+ return thread?.messagePaginator ?? channel.messagePaginator;
+};
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 438a858a08..de86d631c0 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -103,6 +103,7 @@
"aria/File input": "Dateieingabe",
"aria/File upload": "Datei hochladen",
"aria/Flag Message": "Nachricht melden",
+ "aria/Go back": "Zurück",
"aria/Image failed to load": "Bild konnte nicht geladen werden",
"aria/Image input": "Bildeingabe",
"aria/Increase value": "Wert erhöhen",
@@ -149,6 +150,7 @@
"aria/Retry upload": "Upload erneut versuchen",
"aria/Review bounced message": "Zurückgewiesene Nachricht prüfen",
"aria/Search results": "Suchergebnisse",
+ "aria/Search results header filter button": "Suchergebnisse-Kopfzeilen-Filterbutton",
"aria/Search results header filter button for: {{ source }}": "Filterbutton in der Kopfzeile der Suchergebnisse für: {{ source }}",
"aria/Seek audio position": "Audioposition suchen",
"aria/Select Channel: {{ channelName }}": "Kanal auswählen: {{ channelName }}",
@@ -239,6 +241,7 @@
"End vote": "Abstimmung beenden",
"Enforce unique vote is enabled": "Eindeutige Abstimmung ist aktiviert",
"Error": "Fehler",
+ "Error · Unsent": "Fehler · Nicht gesendet",
"Error adding flag": "Fehler beim Hinzufügen des Flags",
"Error adding members": "Error adding members",
"Error blocking user": "Fehler beim Blockieren des Benutzers",
@@ -390,6 +393,8 @@
"mention/Here Description": "Alle Online-Mitglieder in diesem Kanal benachrichtigen",
"Menu": "Menü",
"Message deleted": "Nachricht gelöscht",
+ "Message Failed · Click to try again": "Nachricht fehlgeschlagen · Klicken, um es erneut zu versuchen",
+ "Message Failed · Unauthorized": "Nachricht fehlgeschlagen · Nicht autorisiert",
"Message failed to send": "Nachricht konnte nicht gesendet werden",
"Message has been successfully flagged": "Nachricht wurde erfolgreich gemeldet",
"Message marked as unread": "Nachricht als ungelesen markiert",
@@ -505,6 +510,8 @@
"Searching...": "Suchen...",
"searchResultsCount_one": "1 Ergebnis",
"searchResultsCount_other": "{{ count }} Ergebnisse",
+ "See all options ({{count}})_one": "Alle Optionen anzeigen ({{count}})",
+ "See all options ({{count}})_other": "Alle Optionen anzeigen ({{count}})",
"Select a thread to continue the conversation": "Wähle einen Thread aus, um die Unterhaltung fortzusetzen",
"Select more than one option": "Mehr als eine Option auswählen",
"Select one": "Eine auswählen",
@@ -528,6 +535,7 @@
"Share Location": "Standort teilen",
"Shared live location": "Geteilter Live-Standort",
"Shared location": "Geteilter Standort",
+ "Show all": "Alle anzeigen",
"Shuffle": "Mischen",
"size limit": "Größenbeschränkung",
"Slow Mode ON": "Langsamer Modus EIN",
@@ -577,6 +585,7 @@
"Translated from {{ language }}": "Übersetzung aus {{ language }}",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "Geben Sie eine Zahl von 2 bis 10 ein",
+ "Type your message": "Nachricht eingeben",
"Unarchive": "Archivierung aufheben",
"unban-command-args": "[@Benutzername]",
"unban-command-description": "Einen Benutzer entbannen",
@@ -628,5 +637,6 @@
"Wait until all attachments have uploaded": "Bitte warten, bis alle Anhänge hochgeladen wurden",
"Waiting for network…": "Warte auf Netzwerk…",
"You": "Du",
+ "You have no channels currently": "Du hast momentan noch keine Kanäle",
"You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht"
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 8e5c6f4427..d1011b6136 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -103,6 +103,7 @@
"aria/File input": "File input",
"aria/File upload": "File upload",
"aria/Flag Message": "Flag Message",
+ "aria/Go back": "Go back",
"aria/Image failed to load": "Image failed to load",
"aria/Image input": "Image input",
"aria/Increase value": "Increase value",
@@ -149,6 +150,7 @@
"aria/Retry upload": "Retry upload",
"aria/Review bounced message": "Review bounced message",
"aria/Search results": "Search results",
+ "aria/Search results header filter button": "Search results header filter button",
"aria/Search results header filter button for: {{ source }}": "Search results header filter button for: {{ source }}",
"aria/Seek audio position": "Seek audio position",
"aria/Select Channel: {{ channelName }}": "Select Channel: {{ channelName }}",
@@ -239,6 +241,7 @@
"End vote": "End Vote",
"Enforce unique vote is enabled": "Enforce unique vote is enabled",
"Error": "Error",
+ "Error · Unsent": "Error · Unsent",
"Error adding flag": "Error adding flag",
"Error adding members": "Error adding members",
"Error blocking user": "Error blocking user",
@@ -390,6 +393,8 @@
"mention/Here Description": "Notify every online member in this channel",
"Menu": "Menu",
"Message deleted": "Message deleted",
+ "Message Failed · Click to try again": "Message Failed · Click to try again",
+ "Message Failed · Unauthorized": "Message Failed · Unauthorized",
"Message failed to send": "Message failed to send",
"Message has been successfully flagged": "Message has been successfully flagged",
"Message marked as unread": "Message marked as unread",
@@ -505,6 +510,8 @@
"Searching...": "Searching...",
"searchResultsCount_one": "1 result",
"searchResultsCount_other": "{{ count }} results",
+ "See all options ({{count}})_one": "See all options ({{count}})",
+ "See all options ({{count}})_other": "See all options ({{count}})",
"Select a thread to continue the conversation": "Select a thread to continue the conversation",
"Select more than one option": "Select More Than One Option",
"Select one": "Select one",
@@ -528,6 +535,7 @@
"Share Location": "Share Location",
"Shared live location": "Shared live location",
"Shared location": "Shared location",
+ "Show all": "Show all",
"Shuffle": "Shuffle",
"size limit": "size limit",
"Slow Mode ON": "Slow Mode ON",
@@ -577,6 +585,7 @@
"Translated from {{ language }}": "Translated from {{ language }}",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "Type a number from 2 to 10",
+ "Type your message": "Type your message",
"Unarchive": "Unarchive",
"unban-command-args": "[@username]",
"unban-command-description": "Unban a user",
@@ -628,5 +637,6 @@
"Wait until all attachments have uploaded": "Wait until all attachments have uploaded",
"Waiting for network…": "Waiting for network…",
"You": "You",
+ "You have no channels currently": "You have no channels currently",
"You've reached the maximum number of files": "You've reached the maximum number of files"
}
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 7c0d7e176d..d2635bdd77 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -114,6 +114,7 @@
"aria/File input": "Entrada de archivo",
"aria/File upload": "Carga de archivo",
"aria/Flag Message": "Marcar mensaje",
+ "aria/Go back": "Volver",
"aria/Image failed to load": "Error al cargar la imagen",
"aria/Image input": "Entrada de imagen",
"aria/Increase value": "Aumentar valor",
@@ -160,6 +161,7 @@
"aria/Retry upload": "Reintentar carga",
"aria/Review bounced message": "Revisar mensaje rebotado",
"aria/Search results": "Resultados de búsqueda",
+ "aria/Search results header filter button": "Botón de filtro del encabezado de resultados de búsqueda",
"aria/Search results header filter button for: {{ source }}": "Botón de filtro del encabezado de resultados de búsqueda para: {{ source }}",
"aria/Seek audio position": "Buscar posición de audio",
"aria/Select Channel: {{ channelName }}": "Seleccionar canal: {{ channelName }}",
@@ -250,6 +252,7 @@
"End vote": "Finalizar votación",
"Enforce unique vote is enabled": "El voto único está habilitado",
"Error": "Error",
+ "Error · Unsent": "Error · No enviado",
"Error adding flag": "Error al agregar la bandera",
"Error adding members": "Error adding members",
"Error blocking user": "Error al bloquear al usuario",
@@ -404,6 +407,8 @@
"mention/Here Description": "Notificar a todos los miembros en línea de este canal",
"Menu": "Menú",
"Message deleted": "Mensaje eliminado",
+ "Message Failed · Click to try again": "Mensaje fallido · Haga clic para volver a intentarlo",
+ "Message Failed · Unauthorized": "Mensaje fallido · No autorizado",
"Message failed to send": "No se pudo enviar el mensaje",
"Message has been successfully flagged": "El mensaje se marcó correctamente",
"Message marked as unread": "Mensaje marcado como no leído",
@@ -523,6 +528,9 @@
"searchResultsCount_one": "1 resultado",
"searchResultsCount_many": "{{ count }} resultados",
"searchResultsCount_other": "{{ count }} resultados",
+ "See all options ({{count}})_one": "Ver todas las opciones ({{count}})",
+ "See all options ({{count}})_many": "Ver todas las opciones ({{count}})",
+ "See all options ({{count}})_other": "Ver todas las opciones ({{count}})",
"Select a thread to continue the conversation": "Selecciona un hilo para continuar la conversación",
"Select more than one option": "Seleccionar más de una opción",
"Select one": "Seleccionar uno",
@@ -547,6 +555,7 @@
"Share Location": "Compartir ubicación",
"Shared live location": "Ubicación en vivo compartida",
"Shared location": "Ubicación compartida",
+ "Show all": "Mostrar todo",
"Shuffle": "Mezclar",
"size limit": "límite de tamaño",
"Slow Mode ON": "Modo lento activado",
@@ -598,6 +607,7 @@
"Translated from {{ language }}": "Traducido de {{ language }}",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "Escribe un número del 2 al 10",
+ "Type your message": "Escribe tu mensaje",
"Unarchive": "Desarchivar",
"unban-command-args": "[@usuario]",
"unban-command-description": "Quitar la prohibición a un usuario",
@@ -652,5 +662,6 @@
"Wait until all attachments have uploaded": "Espere hasta que se hayan cargado todos los archivos adjuntos",
"Waiting for network…": "Esperando red…",
"You": "Tú",
+ "You have no channels currently": "Actualmente no tienes canales",
"You've reached the maximum number of files": "Has alcanzado el número máximo de archivos"
}
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 62b88faf9d..7cd73db307 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -114,6 +114,7 @@
"aria/File input": "Entrée de fichier",
"aria/File upload": "Téléchargement de fichier",
"aria/Flag Message": "Signaler le message",
+ "aria/Go back": "Retour",
"aria/Image failed to load": "Échec du chargement de l'image",
"aria/Image input": "Entrée d'image",
"aria/Increase value": "Augmenter la valeur",
@@ -160,6 +161,7 @@
"aria/Retry upload": "Réessayer le téléchargement",
"aria/Review bounced message": "Examiner le message rejeté",
"aria/Search results": "Résultats de recherche",
+ "aria/Search results header filter button": "Bouton de filtre d'en-tête des résultats de recherche",
"aria/Search results header filter button for: {{ source }}": "Bouton de filtre d'en-tête des résultats de recherche pour : {{ source }}",
"aria/Seek audio position": "Rechercher la position audio",
"aria/Select Channel: {{ channelName }}": "Sélectionner le canal : {{ channelName }}",
@@ -250,6 +252,7 @@
"End vote": "Fin du vote",
"Enforce unique vote is enabled": "Le vote unique est activé",
"Error": "Erreur",
+ "Error · Unsent": "Erreur - Non envoyé",
"Error adding flag": "Erreur lors de l'ajout du signalement",
"Error adding members": "Error adding members",
"Error blocking user": "Erreur lors du blocage de l'utilisateur",
@@ -404,6 +407,8 @@
"mention/Here Description": "Notifier tous les membres en ligne dans ce canal",
"Menu": "Menu",
"Message deleted": "Message supprimé",
+ "Message Failed · Click to try again": "Échec de l'envoi du message - Cliquez pour réessayer",
+ "Message Failed · Unauthorized": "Échec de l'envoi du message - Non autorisé",
"Message failed to send": "Échec de l'envoi du message",
"Message has been successfully flagged": "Le message a été signalé avec succès",
"Message marked as unread": "Message marqué comme non lu",
@@ -523,6 +528,9 @@
"searchResultsCount_one": "1 résultat",
"searchResultsCount_many": "{{ count }} résultats",
"searchResultsCount_other": "{{ count }} résultats",
+ "See all options ({{count}})_one": "Voir toutes les options ({{count}})",
+ "See all options ({{count}})_many": "Voir toutes les options ({{count}})",
+ "See all options ({{count}})_other": "Voir toutes les options ({{count}})",
"Select a thread to continue the conversation": "Sélectionnez un fil pour continuer la conversation",
"Select more than one option": "Sélectionner plus d'une option",
"Select one": "Sélectionner un",
@@ -547,6 +555,7 @@
"Share Location": "Partager l'emplacement",
"Shared live location": "Emplacement en direct partagé",
"Shared location": "Position partagée",
+ "Show all": "Tout afficher",
"Shuffle": "Mélanger",
"size limit": "limite de taille",
"Slow Mode ON": "Mode lent activé",
@@ -598,6 +607,7 @@
"Translated from {{ language }}": "Traduit du {{ language }}",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "Tapez un nombre de 2 à 10",
+ "Type your message": "Tapez votre message",
"Unarchive": "Désarchiver",
"unban-command-args": "[@nomdutilisateur]",
"unban-command-description": "Débannir un utilisateur",
@@ -652,5 +662,6 @@
"Wait until all attachments have uploaded": "Attendez que toutes les pièces jointes soient téléchargées",
"Waiting for network…": "En attente du réseau…",
"You": "Vous",
+ "You have no channels currently": "Vous n'avez actuellement aucun canal",
"You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers"
}
diff --git a/src/i18n/hi.json b/src/i18n/hi.json
index 839d362181..0a12187d3a 100644
--- a/src/i18n/hi.json
+++ b/src/i18n/hi.json
@@ -103,6 +103,7 @@
"aria/File input": "फ़ाइल इनपुट",
"aria/File upload": "फ़ाइल अपलोड",
"aria/Flag Message": "संदेश फ्लैग करें",
+ "aria/Go back": "वापस जाएं",
"aria/Image failed to load": "छवि लोड होने में विफल",
"aria/Image input": "छवि इनपुट",
"aria/Increase value": "मान बढ़ाएं",
@@ -149,6 +150,7 @@
"aria/Retry upload": "अपलोड पुनः प्रयास करें",
"aria/Review bounced message": "वापस लौटा संदेश समीक्षा करें",
"aria/Search results": "खोज परिणाम",
+ "aria/Search results header filter button": "खोज परिणाम हेडर फ़िल्टर बटन",
"aria/Search results header filter button for: {{ source }}": "{{ source }} के लिए खोज परिणाम शीर्षक फ़िल्टर बटन",
"aria/Seek audio position": "ऑडियो स्थिति खोजें",
"aria/Select Channel: {{ channelName }}": "चैनल चुनें: {{ channelName }}",
@@ -239,6 +241,7 @@
"End vote": "मत समाप्त करें",
"Enforce unique vote is enabled": "अनोखा वोट सक्षम है",
"Error": "त्रुटि",
+ "Error · Unsent": "फेल",
"Error adding flag": "ध्वज जोड़ने में त्रुटि",
"Error adding members": "Error adding members",
"Error blocking user": "उपयोगकर्ता को ब्लॉक करने में त्रुटि",
@@ -391,6 +394,8 @@
"mention/Here Description": "इस चैनल के सभी ऑनलाइन सदस्यों को सूचित करें",
"Menu": "मेन्यू",
"Message deleted": "मैसेज हटा दिया गया",
+ "Message Failed · Click to try again": "मैसेज फ़ैल - पुनः कोशिश करें",
+ "Message Failed · Unauthorized": "मैसेज फ़ैल - अनधिकृत",
"Message failed to send": "संदेश भेजने में विफल",
"Message has been successfully flagged": "मैसेज को फ्लैग कर दिया गया है",
"Message marked as unread": "संदेश को अपठित के रूप में चिह्नित किया गया",
@@ -506,6 +511,8 @@
"Searching...": "खोज कर...",
"searchResultsCount_one": "1 परिणाम",
"searchResultsCount_other": "{{ count }} परिणाम",
+ "See all options ({{count}})_one": "सभी विकल्प देखें ({{count}})",
+ "See all options ({{count}})_other": "सभी विकल्प देखें ({{count}})",
"Select a thread to continue the conversation": "बातचीत जारी रखने के लिए एक थ्रेड चुनें",
"Select more than one option": "एक से अधिक विकल्प चुनें",
"Select one": "एक चुनें",
@@ -529,6 +536,7 @@
"Share Location": "स्थान साझा करें",
"Shared live location": "साझा किया गया लाइव स्थान",
"Shared location": "साझा स्थान",
+ "Show all": "सभी दिखाएँ",
"Shuffle": "मिश्रित करें",
"size limit": "आकार सीमा",
"Slow Mode ON": "स्लो मोड ऑन",
@@ -578,6 +586,7 @@
"Translated from {{ language }}": "{{ language }} से अनुवादित",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "2 से 10 तक का एक नंबर टाइप करें",
+ "Type your message": "अपना मैसेज लिखे",
"Unarchive": "अनआर्काइव",
"unban-command-args": "[@उपयोगकर्तनाम]",
"unban-command-description": "एक उपयोगकर्ता को प्रतिषेध से मुक्त करें",
@@ -629,5 +638,6 @@
"Wait until all attachments have uploaded": "सभी अटैचमेंट अपलोड होने तक प्रतीक्षा करें",
"Waiting for network…": "नेटवर्क की प्रतीक्षा…",
"You": "आप",
+ "You have no channels currently": "आपके पास कोई चैनल नहीं है",
"You've reached the maximum number of files": "आप अधिकतम फ़ाइलों तक पहुँच गए हैं"
}
diff --git a/src/i18n/it.json b/src/i18n/it.json
index 385f128c0b..0fdb39aa15 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -114,6 +114,7 @@
"aria/File input": "Input di file",
"aria/File upload": "Caricamento di file",
"aria/Flag Message": "Segnala messaggio",
+ "aria/Go back": "Indietro",
"aria/Image failed to load": "Caricamento immagine non riuscito",
"aria/Image input": "Input di immagine",
"aria/Increase value": "Aumenta valore",
@@ -160,6 +161,7 @@
"aria/Retry upload": "Riprova caricamento",
"aria/Review bounced message": "Rivedi il messaggio respinto",
"aria/Search results": "Risultati della ricerca",
+ "aria/Search results header filter button": "Pulsante filtro intestazione risultati ricerca",
"aria/Search results header filter button for: {{ source }}": "Pulsante filtro intestazione risultati di ricerca per: {{ source }}",
"aria/Seek audio position": "Cerca posizione audio",
"aria/Select Channel: {{ channelName }}": "Seleziona canale: {{ channelName }}",
@@ -250,6 +252,7 @@
"End vote": "Termina il voto",
"Enforce unique vote is enabled": "Il voto unico è abilitato",
"Error": "Errore",
+ "Error · Unsent": "Errore · Non inviato",
"Error adding flag": "Errore durante l'aggiunta del flag",
"Error adding members": "Error adding members",
"Error blocking user": "Errore durante il blocco dell'utente",
@@ -404,6 +407,8 @@
"mention/Here Description": "Notifica tutti i membri online in questo canale",
"Menu": "Menù",
"Message deleted": "Messaggio cancellato",
+ "Message Failed · Click to try again": "Invio messaggio fallito · Clicca per riprovare",
+ "Message Failed · Unauthorized": "Invio messaggio fallito · Non autorizzato",
"Message failed to send": "Invio del messaggio non riuscito",
"Message has been successfully flagged": "Il messaggio è stato segnalato con successo",
"Message marked as unread": "Messaggio contrassegnato come non letto",
@@ -523,6 +528,9 @@
"searchResultsCount_one": "1 risultato",
"searchResultsCount_many": "{{ count }} risultati",
"searchResultsCount_other": "{{ count }} risultati",
+ "See all options ({{count}})_one": "Vedi tutte le opzioni ({{count}})",
+ "See all options ({{count}})_many": "Vedi tutte le opzioni ({{count}})",
+ "See all options ({{count}})_other": "Vedi tutte le opzioni ({{count}})",
"Select a thread to continue the conversation": "Seleziona un thread per continuare la conversazione",
"Select more than one option": "Seleziona più di un'opzione",
"Select one": "Seleziona uno",
@@ -547,6 +555,7 @@
"Share Location": "Condividi posizione",
"Shared live location": "Posizione live condivisa",
"Shared location": "Posizione condivisa",
+ "Show all": "Mostra tutto",
"Shuffle": "Mescolare",
"size limit": "limite di dimensione",
"Slow Mode ON": "Modalità lenta attivata",
@@ -598,6 +607,7 @@
"Translated from {{ language }}": "Tradotto da {{ language }}",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "Digita un numero da 2 a 10",
+ "Type your message": "Scrivi il tuo messaggio",
"Unarchive": "Ripristina",
"unban-command-args": "[@nomeutente]",
"unban-command-description": "Togliere il divieto a un utente",
@@ -652,5 +662,6 @@
"Wait until all attachments have uploaded": "Attendi il caricamento di tutti gli allegati",
"Waiting for network…": "In attesa della rete…",
"You": "Tu",
+ "You have no channels currently": "Al momento non sono presenti canali",
"You've reached the maximum number of files": "Hai raggiunto il numero massimo di file"
}
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index 7b0b93b481..d8f1d8e439 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -99,6 +99,7 @@
"aria/File input": "ファイル入力",
"aria/File upload": "ファイルアップロード",
"aria/Flag Message": "メッセージをフラグ",
+ "aria/Go back": "戻る",
"aria/Image failed to load": "画像の読み込みに失敗しました",
"aria/Image input": "画像入力",
"aria/Increase value": "値を増やす",
@@ -145,6 +146,7 @@
"aria/Retry upload": "アップロードを再試行",
"aria/Review bounced message": "バウンスされたメッセージを確認",
"aria/Search results": "検索結果",
+ "aria/Search results header filter button": "検索結果ヘッダーフィルターボタン",
"aria/Search results header filter button for: {{ source }}": "{{ source }} の検索結果ヘッダーのフィルターボタン",
"aria/Seek audio position": "音声位置をシーク",
"aria/Select Channel: {{ channelName }}": "チャンネルを選択: {{ channelName }}",
@@ -235,6 +237,7 @@
"End vote": "投票を終了",
"Enforce unique vote is enabled": "一意の投票が有効になっています",
"Error": "エラー",
+ "Error · Unsent": "エラー・未送信",
"Error adding flag": "フラグを追加のエラーが発生しました",
"Error adding members": "Error adding members",
"Error blocking user": "ユーザーのブロック中にエラーが発生しました",
@@ -383,6 +386,8 @@
"mention/Here Description": "このチャンネルのオンライン中の全メンバーに通知",
"Menu": "メニュー",
"Message deleted": "メッセージが削除されました",
+ "Message Failed · Click to try again": "メッセージが失敗しました · クリックして再試行してください",
+ "Message Failed · Unauthorized": "メッセージが失敗しました · 許可されていません",
"Message failed to send": "メッセージの送信に失敗しました",
"Message has been successfully flagged": "メッセージに正常にフラグが付けられました",
"Message marked as unread": "メッセージを未読にしました",
@@ -496,6 +501,8 @@
"Searching...": "検索中...",
"searchResultsCount_one": "1件の結果",
"searchResultsCount_other": "{{ count }}件の結果",
+ "See all options ({{count}})_one": "すべてのオプションを見る ({{count}})",
+ "See all options ({{count}})_other": "すべてのオプションを見る ({{count}})",
"Select a thread to continue the conversation": "会話を続けるにはスレッドを選択してください",
"Select more than one option": "複数の選択肢を選ぶ",
"Select one": "1つ選択",
@@ -519,6 +526,7 @@
"Share Location": "位置情報を共有",
"Shared live location": "共有されたライブ位置情報",
"Shared location": "共有された位置情報",
+ "Show all": "すべて表示",
"Shuffle": "シャッフル",
"size limit": "サイズ制限",
"Slow Mode ON": "スローモードオン",
@@ -566,6 +574,7 @@
"Translated from {{ language }}": "{{ language }}から翻訳",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "2から10までの数字を入力してください",
+ "Type your message": "メッセージを入力してください",
"Unarchive": "アーカイブ解除",
"unban-command-args": "[@ユーザ名]",
"unban-command-description": "ユーザーの禁止を解除する",
@@ -615,5 +624,6 @@
"Wait until all attachments have uploaded": "すべての添付ファイルがアップロードされるまでお待ちください",
"Waiting for network…": "ネットワークを待機中…",
"You": "あなた",
+ "You have no channels currently": "現在チャンネルはありません",
"You've reached the maximum number of files": "ファイルの最大数に達しました"
}
diff --git a/src/i18n/ko.json b/src/i18n/ko.json
index 783de931e3..73cabfa43c 100644
--- a/src/i18n/ko.json
+++ b/src/i18n/ko.json
@@ -99,6 +99,7 @@
"aria/File input": "파일 입력",
"aria/File upload": "파일 업로드",
"aria/Flag Message": "메시지 신고",
+ "aria/Go back": "뒤로 가기",
"aria/Image failed to load": "이미지를 불러오지 못했습니다",
"aria/Image input": "이미지 입력",
"aria/Increase value": "값 증가",
@@ -145,6 +146,7 @@
"aria/Retry upload": "업로드 다시 시도",
"aria/Review bounced message": "반송된 메시지 검토",
"aria/Search results": "검색 결과",
+ "aria/Search results header filter button": "검색 결과 헤더 필터 버튼",
"aria/Search results header filter button for: {{ source }}": "{{ source }}에 대한 검색 결과 헤더 필터 버튼",
"aria/Seek audio position": "오디오 위치 탐색",
"aria/Select Channel: {{ channelName }}": "채널 선택: {{ channelName }}",
@@ -235,6 +237,7 @@
"End vote": "투표 종료",
"Enforce unique vote is enabled": "고유 투표가 활성화되었습니다",
"Error": "오류",
+ "Error · Unsent": "오류 · 전송되지 않음",
"Error adding flag": "플래그를 추가하는 동안 오류가 발생했습니다.",
"Error adding members": "Error adding members",
"Error blocking user": "사용자 차단 중 오류 발생",
@@ -383,6 +386,8 @@
"mention/Here Description": "이 채널의 모든 온라인 멤버에게 알림",
"Menu": "메뉴",
"Message deleted": "메시지가 삭제되었습니다.",
+ "Message Failed · Click to try again": "메시지 실패 · 다시 시도하려면 클릭하세요.",
+ "Message Failed · Unauthorized": "메시지 실패 · 승인되지 않음",
"Message failed to send": "메시지 전송 실패",
"Message has been successfully flagged": "메시지에 플래그가 지정되었습니다.",
"Message marked as unread": "메시지를 읽지 않음으로 표시했습니다",
@@ -496,6 +501,8 @@
"Searching...": "수색...",
"searchResultsCount_one": "1개의 결과",
"searchResultsCount_other": "{{ count }}개 결과",
+ "See all options ({{count}})_one": "모든 옵션 보기 ({{count}})",
+ "See all options ({{count}})_other": "모든 옵션 보기 ({{count}})",
"Select a thread to continue the conversation": "대화를 계속하려면 스레드를 선택하세요",
"Select more than one option": "하나 이상의 선택지 선택",
"Select one": "하나 선택",
@@ -519,6 +526,7 @@
"Share Location": "위치 공유",
"Shared live location": "공유된 라이브 위치",
"Shared location": "공유된 위치",
+ "Show all": "모두 보기",
"Shuffle": "셔플",
"size limit": "크기 제한",
"Slow Mode ON": "슬로우 모드 켜짐",
@@ -566,6 +574,7 @@
"Translated from {{ language }}": "{{ language }}(으)로 번역됨",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "2에서 10 사이의 숫자를 입력하세요",
+ "Type your message": "메시지 입력",
"Unarchive": "아카이브 해제",
"unban-command-args": "[@사용자이름]",
"unban-command-description": "사용자 차단 해제",
@@ -615,5 +624,6 @@
"Wait until all attachments have uploaded": "모든 첨부 파일이 업로드될 때까지 기다립니다.",
"Waiting for network…": "네트워크 대기 중…",
"You": "당신",
+ "You have no channels currently": "현재 채널이 없습니다.",
"You've reached the maximum number of files": "최대 파일 수에 도달했습니다."
}
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
index e686c72b78..ec64cb3cc0 100644
--- a/src/i18n/nl.json
+++ b/src/i18n/nl.json
@@ -103,6 +103,7 @@
"aria/File input": "Bestandsinvoer",
"aria/File upload": "Bestand uploaden",
"aria/Flag Message": "Bericht markeren",
+ "aria/Go back": "Ga terug",
"aria/Image failed to load": "Afbeelding laden mislukt",
"aria/Image input": "Afbeelding invoeren",
"aria/Increase value": "Waarde verhogen",
@@ -149,6 +150,7 @@
"aria/Retry upload": "Upload opnieuw proberen",
"aria/Review bounced message": "Controleer teruggestuurd bericht",
"aria/Search results": "Zoekresultaten",
+ "aria/Search results header filter button": "Zoekresultaten header filter knop",
"aria/Search results header filter button for: {{ source }}": "Filterknop koptekst zoekresultaten voor: {{ source }}",
"aria/Seek audio position": "Audiopositie zoeken",
"aria/Select Channel: {{ channelName }}": "Kanaal selecteren: {{ channelName }}",
@@ -239,6 +241,7 @@
"End vote": "Einde stem",
"Enforce unique vote is enabled": "Unieke stem is ingeschakeld",
"Error": "Fout",
+ "Error · Unsent": "Fout · niet verzonden",
"Error adding flag": "Fout bij toevoegen van vlag",
"Error adding members": "Error adding members",
"Error blocking user": "Fout bij blokkeren van gebruiker",
@@ -390,6 +393,8 @@
"mention/Here Description": "Alle online leden in dit kanaal informeren",
"Menu": "Menu",
"Message deleted": "Bericht verwijderd",
+ "Message Failed · Click to try again": "Bericht mislukt, klik om het nogmaals te proberen",
+ "Message Failed · Unauthorized": "Bericht mislukt, ongeautoriseerd",
"Message failed to send": "Bericht kon niet worden verzonden",
"Message has been successfully flagged": "Bericht is succesvol gemarkeerd",
"Message marked as unread": "Bericht gemarkeerd als ongelezen",
@@ -505,6 +510,8 @@
"Searching...": "Zoeken...",
"searchResultsCount_one": "1 resultaat",
"searchResultsCount_other": "{{ count }} resultaten",
+ "See all options ({{count}})_one": "Bekijk alle opties ({{count}})",
+ "See all options ({{count}})_other": "Bekijk alle opties ({{count}})",
"Select a thread to continue the conversation": "Selecteer een thread om het gesprek voort te zetten",
"Select more than one option": "Selecteer meer dan één optie",
"Select one": "Selecteer er een",
@@ -528,6 +535,7 @@
"Share Location": "Locatie delen",
"Shared live location": "Gedeelde live locatie",
"Shared location": "Gedeelde locatie",
+ "Show all": "Toon alles",
"Shuffle": "Schudden",
"size limit": "grootte limiet",
"Slow Mode ON": "Langzame modus aan",
@@ -579,6 +587,7 @@
"Translated from {{ language }}": "Vertaald uit {{ language }}",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "Typ een getal van 2 tot 10",
+ "Type your message": "Type je bericht",
"Unarchive": "Uit archief halen",
"unban-command-args": "[@gebruikersnaam]",
"unban-command-description": "Een gebruiker debannen",
@@ -630,5 +639,6 @@
"Wait until all attachments have uploaded": "Wacht tot alle bijlagen zijn geüpload",
"Waiting for network…": "Wachten op netwerk…",
"You": "Jij",
+ "You have no channels currently": "Er zijn geen chats beschikbaar",
"You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt"
}
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
index 3af6f2c1bf..6d95d6bdda 100644
--- a/src/i18n/pt.json
+++ b/src/i18n/pt.json
@@ -114,6 +114,7 @@
"aria/File input": "Entrada de arquivo",
"aria/File upload": "Carregar arquivo",
"aria/Flag Message": "Reportar mensagem",
+ "aria/Go back": "Voltar",
"aria/Image failed to load": "Falha ao carregar a imagem",
"aria/Image input": "Entrada de imagem",
"aria/Increase value": "Aumentar valor",
@@ -160,6 +161,7 @@
"aria/Retry upload": "Tentar upload novamente",
"aria/Review bounced message": "Revisar mensagem devolvida",
"aria/Search results": "Resultados da pesquisa",
+ "aria/Search results header filter button": "Botão de filtro do cabeçalho dos resultados da pesquisa",
"aria/Search results header filter button for: {{ source }}": "Botão de filtro do cabeçalho dos resultados da pesquisa para: {{ source }}",
"aria/Seek audio position": "Buscar posição do áudio",
"aria/Select Channel: {{ channelName }}": "Selecionar canal: {{ channelName }}",
@@ -250,6 +252,7 @@
"End vote": "Encerrar votação",
"Enforce unique vote is enabled": "Voto único está habilitado",
"Error": "Erro",
+ "Error · Unsent": "Erro · Não enviado",
"Error adding flag": "Erro ao reportar",
"Error adding members": "Error adding members",
"Error blocking user": "Erro ao bloquear usuário",
@@ -404,6 +407,8 @@
"mention/Here Description": "Notificar todos os membros online neste canal",
"Menu": "Menu",
"Message deleted": "Mensagem apagada",
+ "Message Failed · Click to try again": "A mensagem falhou · Clique para tentar novamente",
+ "Message Failed · Unauthorized": "A mensagem falhou · não autorizado",
"Message failed to send": "Falha ao enviar a mensagem",
"Message has been successfully flagged": "A mensagem foi reportada com sucesso",
"Message marked as unread": "Mensagem marcada como não lida",
@@ -523,6 +528,9 @@
"searchResultsCount_one": "1 resultado",
"searchResultsCount_many": "{{ count }} resultados",
"searchResultsCount_other": "{{ count }} resultados",
+ "See all options ({{count}})_one": "Ver todas as opções ({{count}})",
+ "See all options ({{count}})_many": "Ver todas as opções ({{count}})",
+ "See all options ({{count}})_other": "Ver todas as opções ({{count}})",
"Select a thread to continue the conversation": "Selecione uma thread para continuar a conversa",
"Select more than one option": "Selecionar mais de uma opção",
"Select one": "Selecionar um",
@@ -547,6 +555,7 @@
"Share Location": "Compartilhar localização",
"Shared live location": "Localização ao vivo compartilhada",
"Shared location": "Localização compartilhada",
+ "Show all": "Mostrar tudo",
"Shuffle": "Embaralhar",
"size limit": "limite de tamanho",
"Slow Mode ON": "Modo lento LIGADO",
@@ -598,6 +607,7 @@
"Translated from {{ language }}": "Traduzido de {{ language }}",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "Digite um número de 2 a 10",
+ "Type your message": "Digite sua mensagem",
"Unarchive": "Desarquivar",
"unban-command-args": "[@nomedeusuário]",
"unban-command-description": "Desbanir um usuário",
@@ -652,5 +662,6 @@
"Wait until all attachments have uploaded": "Espere até que todos os anexos tenham sido carregados",
"Waiting for network…": "Aguardando rede…",
"You": "Você",
+ "You have no channels currently": "Você não tem canais atualmente",
"You've reached the maximum number of files": "Você atingiu o número máximo de arquivos"
}
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index 6403121254..738a923b01 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -126,6 +126,7 @@
"aria/File input": "Ввод файла",
"aria/File upload": "Загрузка файла",
"aria/Flag Message": "Пожаловаться на сообщение",
+ "aria/Go back": "Назад",
"aria/Image failed to load": "Не удалось загрузить изображение",
"aria/Image input": "Ввод изображения",
"aria/Increase value": "Увеличить значение",
@@ -172,6 +173,7 @@
"aria/Retry upload": "Повторить загрузку",
"aria/Review bounced message": "Проверить отклонённое сообщение",
"aria/Search results": "Результаты поиска",
+ "aria/Search results header filter button": "Кнопка фильтра заголовка результатов поиска",
"aria/Search results header filter button for: {{ source }}": "Кнопка фильтра заголовка результатов поиска для: {{ source }}",
"aria/Seek audio position": "Перемотать аудио",
"aria/Select Channel: {{ channelName }}": "Выбрать канал: {{ channelName }}",
@@ -262,6 +264,7 @@
"End vote": "Закончить голосование",
"Enforce unique vote is enabled": "Уникальное голосование включено",
"Error": "Ошибка",
+ "Error · Unsent": "Ошибка · Не отправлено",
"Error adding flag": "Ошибка добавления флага",
"Error adding members": "Error adding members",
"Error blocking user": "Ошибка при блокировке пользователя",
@@ -312,9 +315,9 @@
"File is too large: {{ size }}, maximum upload size is {{ limit }}": "Файл слишком большой: {{ size }}, максимальный размер загрузки составляет {{ limit }}",
"File too large": "Файл слишком большой",
"fileCount_one": "{{ count }} файл",
+ "fileCount_two": "{{ count }} файла",
"fileCount_few": "{{ count }} файла",
"fileCount_four": "{{ count }} файла",
- "fileCount_two": "{{ count }} файла",
"fileCount_many": "{{ count }} файлов",
"fileCount_other": "{{ count }} файлов",
"fileCount_three": "{{ count }} файла",
@@ -422,6 +425,8 @@
"mention/Here Description": "Уведомить всех онлайн-участников в этом канале",
"Menu": "Меню",
"Message deleted": "Сообщение удалено",
+ "Message Failed · Click to try again": "Ошибка отправки сообщения · Нажмите чтобы повторить",
+ "Message Failed · Unauthorized": "Ошибка отправки сообщения · Неавторизованный",
"Message failed to send": "Не удалось отправить сообщение",
"Message has been successfully flagged": "Жалоба на сообщение была принята",
"Message marked as unread": "Сообщение помечено как непрочитанное",
@@ -545,6 +550,10 @@
"searchResultsCount_few": "{{ count }} результата",
"searchResultsCount_many": "{{ count }} результатов",
"searchResultsCount_other": "{{ count }} результатов",
+ "See all options ({{count}})_one": "Посмотреть все варианты ({{count}})",
+ "See all options ({{count}})_few": "Посмотреть все варианты ({{count}})",
+ "See all options ({{count}})_many": "Посмотреть все варианты ({{count}})",
+ "See all options ({{count}})_other": "Посмотреть все варианты ({{count}})",
"Select a thread to continue the conversation": "Выберите тред, чтобы продолжить беседу",
"Select more than one option": "Выберите более одного варианта",
"Select one": "Выберите один",
@@ -570,6 +579,7 @@
"Share Location": "Поделиться местоположением",
"Shared live location": "Общее местоположение в прямом эфире",
"Shared location": "Общее местоположение",
+ "Show all": "Показать все",
"Shuffle": "Перемешать",
"size limit": "ограничение размера",
"Slow Mode ON": "Медленный режим включен",
@@ -623,6 +633,7 @@
"Translated from {{ language }}": "Переведено с {{ language }}",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "Введите число от 2 до 10",
+ "Type your message": "Ваше сообщение",
"Unarchive": "Удалить из архива",
"unban-command-args": "[@имяпользователя]",
"unban-command-description": "Разблокировать пользователя",
@@ -680,5 +691,6 @@
"Wait until all attachments have uploaded": "Подождите, пока все вложения загрузятся",
"Waiting for network…": "Ожидание сети…",
"You": "Вы",
+ "You have no channels currently": "У вас нет каналов в данный момент",
"You've reached the maximum number of files": "Вы достигли максимального количества файлов"
}
diff --git a/src/i18n/tr.json b/src/i18n/tr.json
index 4daa1bd7e4..7943d8473b 100644
--- a/src/i18n/tr.json
+++ b/src/i18n/tr.json
@@ -103,6 +103,7 @@
"aria/File input": "Dosya girişi",
"aria/File upload": "Dosya yükleme",
"aria/Flag Message": "Mesajı bayrakla",
+ "aria/Go back": "Geri git",
"aria/Image failed to load": "Görsel yüklenemedi",
"aria/Image input": "Resim girişi",
"aria/Increase value": "Değeri artır",
@@ -149,6 +150,7 @@
"aria/Retry upload": "Yüklemeyi Tekrar Dene",
"aria/Review bounced message": "Geri dönen mesajı incele",
"aria/Search results": "Arama sonuçları",
+ "aria/Search results header filter button": "Arama sonuçları başlık filtre düğmesi",
"aria/Search results header filter button for: {{ source }}": "{{ source }} için arama sonuçları başlık filtre düğmesi",
"aria/Seek audio position": "Ses konumunu ara",
"aria/Select Channel: {{ channelName }}": "Kanal seç: {{ channelName }}",
@@ -239,6 +241,7 @@
"End vote": "Oyu bitir",
"Enforce unique vote is enabled": "Benzersiz oy etkinleştirildi",
"Error": "Hata",
+ "Error · Unsent": "Hata · Gönderilemedi",
"Error adding flag": "Bayrak eklenirken hata oluştu",
"Error adding members": "Error adding members",
"Error blocking user": "Kullanıcı engellenirken hata oluştu",
@@ -390,6 +393,8 @@
"mention/Here Description": "Bu kanaldaki tüm çevrimiçi üyeleri bildir",
"Menu": "Menü",
"Message deleted": "Mesaj silindi",
+ "Message Failed · Click to try again": "Mesaj Başarısız · Tekrar denemek için tıklayın",
+ "Message Failed · Unauthorized": "Mesaj Başarısız · Yetkisiz",
"Message failed to send": "Mesaj gönderilemedi",
"Message has been successfully flagged": "Mesaj başarıyla bayraklandı",
"Message marked as unread": "Mesaj okunmadı olarak işaretlendi",
@@ -505,6 +510,8 @@
"Searching...": "Aranıyor...",
"searchResultsCount_one": "1 sonuç",
"searchResultsCount_other": "{{ count }} sonuç",
+ "See all options ({{count}})_one": "Tüm seçenekleri göster ({{count}})",
+ "See all options ({{count}})_other": "Tüm seçenekleri göster ({{count}})",
"Select a thread to continue the conversation": "Sohbeti sürdürmek için bir ileti dizisi seçin",
"Select more than one option": "Birden fazla seçenek seçin",
"Select one": "Birini seçin",
@@ -528,6 +535,7 @@
"Share Location": "Konum Paylaş",
"Shared live location": "Paylaşılan canlı konum",
"Shared location": "Paylaşılan konum",
+ "Show all": "Tümünü göster",
"Shuffle": "Karıştır",
"size limit": "boyut sınırı",
"Slow Mode ON": "Yavaş Mod Açık",
@@ -577,6 +585,7 @@
"Translated from {{ language }}": "{{ language }} dilinden çevrildi",
"translationBuilderTopic/notification": "{{value, notification}}",
"Type a number from 2 to 10": "2 ile 10 arasında bir sayı yazın",
+ "Type your message": "Mesajınızı yazın",
"Unarchive": "Arşivden çıkar",
"unban-command-args": "[@kullanıcıadı]",
"unban-command-description": "Bir kullanıcının yasağını kaldır",
@@ -628,5 +637,6 @@
"Wait until all attachments have uploaded": "Tüm ekler yüklenene kadar bekleyin",
"Waiting for network…": "Ağ bekleniyor…",
"You": "Sen",
+ "You have no channels currently": "Henüz kanalınız yok",
"You've reached the maximum number of files": "Maksimum dosya sayısına ulaştınız"
}
diff --git a/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx
index b1dc43e6ba..f305ac5bfc 100644
--- a/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx
+++ b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx
@@ -1,11 +1,7 @@
import clsx from 'clsx';
import React, { useCallback, useState } from 'react';
-import {
- useChannelStateContext,
- useComponentContext,
- useTranslationContext,
-} from '../../context';
+import { useChannel, useComponentContext, useTranslationContext } from '../../context';
import {
type ChannelAvatarProps,
ChannelAvatar as DefaultChannelAvatar,
@@ -32,7 +28,7 @@ export const AvatarWithChannelDetail = ({
...avatarProps
}: AvatarWithChannelDetailProps) => {
const { t } = useTranslationContext();
- const { channel } = useChannelStateContext();
+ const channel = useChannel();
const { Avatar: ContextAvatar, Modal = GlobalModal } = useComponentContext();
const [isModalOpen, setIsModalOpen] = useState(false);
diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx
index b4979b38f1..6a3df8dd84 100644
--- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx
+++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx
@@ -76,7 +76,10 @@ export const ChannelManagementInfoBody = ({
const { muted: channelMuted } = useIsChannelMuted(channel);
const userMuted = useIsUserMuted(otherMemberUserId);
const membership = useChannelMembershipState(channel);
- const onlineStatusText = useChannelHeaderOnlineStatus({ channel });
+ // MERGE-RECONCILE: useChannelHeaderOnlineStatus was reworked (PR #2909) to read the channel
+ // from context (useChannel) instead of an argument. Verify this view renders within a
+ // Channel/ChannelInstance subtree so the context channel matches `channel`.
+ const onlineStatusText = useChannelHeaderOnlineStatus();
const pinned = !!membership.pinned_at;
return (
diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx
index afcc3ff38e..9a0a1aa747 100644
--- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx
+++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx
@@ -1,6 +1,5 @@
import clsx from 'clsx';
import debounce from 'lodash.debounce';
-import uniqBy from 'lodash.uniqby';
import React, {
createContext,
useCallback,
@@ -12,12 +11,12 @@ import React, {
import type { ChannelMemberResponse } from 'stream-chat';
import {
- useChannelListContext,
useChatContext,
useComponentContext,
useModalContext,
useTranslationContext,
} from '../../../../context';
+import { useChatViewNavigation } from '../../../../components/ChatView';
import { useStableCallback } from '../../../../utils';
import { useStateStore } from '../../../../store';
import { Alert } from '../../../../components/Dialog';
@@ -207,8 +206,8 @@ export const useBaseChannelMemberActionSetFilter = (
};
const SendDirectMessageAction = () => {
- const { client, setActiveChannel } = useChatContext();
- const { setChannels } = useChannelListContext();
+ const { channelPaginatorsOrchestrator, client } = useChatContext();
+ const { open } = useChatViewNavigation();
const { close } = useModalContext();
const { channel } = useChannelDetailContext();
const { addNotification } = useNotificationApi();
@@ -225,8 +224,14 @@ const SendDirectMessageAction = () => {
members: [client.userID, targetUserId],
});
await directMessageChannel.watch();
- setActiveChannel(directMessageChannel);
- setChannels?.((channels) => uniqBy([directMessageChannel, ...channels], 'cid'));
+ // Selection is one navigation model: open the DM into a layout slot, then route it into
+ // the channel list(s) that should own it so it appears without a full re-query.
+ open({
+ key: directMessageChannel.cid ?? undefined,
+ kind: 'channel',
+ source: directMessageChannel,
+ });
+ channelPaginatorsOrchestrator.ingestChannel(directMessageChannel);
close();
} catch (error) {
addNotification({
@@ -245,9 +250,9 @@ const SendDirectMessageAction = () => {
channel,
client,
close,
+ channelPaginatorsOrchestrator,
isSending,
- setActiveChannel,
- setChannels,
+ open,
t,
targetUserId,
]);
diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx
index f7d38a0084..05402b7d22 100644
--- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx
+++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx
@@ -1,12 +1,8 @@
import type { LocalMessage, MessageResponse, MessageSearchSource } from 'stream-chat';
import React, { useCallback, useMemo } from 'react';
-import {
- useChannelActionContext,
- useChatContext,
- useModalContext,
- useTranslationContext,
-} from '../../../../context';
+import { useModalContext, useTranslationContext } from '../../../../context';
+import { useChatViewNavigation } from '../../../../components/ChatView';
import { getDateString, isDate } from '../../../../i18n/utils';
import { Avatar } from '../../../../components/Avatar';
import { ListItemLayout } from '../../../../components/ListItemLayout';
@@ -129,11 +125,9 @@ export type PinnedMessagesViewProps = SectionNavigatorSectionContentProps & {
export const PinnedMessagesView: React.ComponentType = ({
searchSource,
}) => {
- const { setActiveChannel } = useChatContext();
+ const { open } = useChatViewNavigation();
const { t } = useTranslationContext();
const { close } = useModalContext();
- // fixme: it is not right to couple the ChannelDetail view with Channel component. We need to have access to channel.messagePaginator.jumpToMessage()
- const { jumpToMessage } = useChannelActionContext();
const { channel } = useChannelDetailContext();
const {
displayedMessages,
@@ -145,11 +139,14 @@ export const PinnedMessagesView: React.ComponentType =
const handleSelectMessage = useCallback(
(message: PinnedMessage) => {
- setActiveChannel(channel);
- jumpToMessage(message.id);
+ // Selection is one navigation model: open the channel into a layout slot.
+ open({ key: channel.cid ?? undefined, kind: 'channel', source: channel });
+ // MERGE-RECONCILE: the deleted ChannelActionContext.jumpToMessage was replaced by the
+ // channel's messagePaginator (PR #2909 / stream-chat message-paginator API).
+ void channel.messagePaginator.jumpToMessage(message.id);
close();
},
- [channel, close, jumpToMessage, setActiveChannel],
+ [channel, close, open],
);
const renderItem = useCallback(
diff --git a/src/plugins/SlotGeometry/SlotGeometry.tsx b/src/plugins/SlotGeometry/SlotGeometry.tsx
new file mode 100644
index 0000000000..e687659749
--- /dev/null
+++ b/src/plugins/SlotGeometry/SlotGeometry.tsx
@@ -0,0 +1,253 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import type { PropsWithChildren } from 'react';
+
+/**
+ * Slot geometry — an opt-in module that reports which registered "slots" are visually **obscured**.
+ *
+ * The ChatView `LayoutController` is purely logical: slots, bindings, a `hidden` flag, history — it
+ * has no idea where slots land on screen. Whether one panel visually *covers* another is a physical
+ * concern owned by the app's CSS (e.g. at narrow widths a secondary panel might be laid out at full
+ * width, collapsing the primary one). This module is the missing physical half:
+ *
+ * - App code registers the DOM element backing each slot (`useRegisterSlotGeometry`).
+ * - It observes those elements (`ResizeObserver` + window resize) and measures real rects.
+ * - It reports which slots are currently obscured — collapsed to ~0 area, or covered by another
+ * registered slot past a threshold — with no knowledge of breakpoints or class names.
+ *
+ * It is opt-in (wrap a subtree in `SlotGeometryProvider`) and **detection only**: it never mutates
+ * the layout. Consumers read `isObscured(slot)` and decide what to do (e.g. release a covering
+ * slot). A declarative rules/auto-close layer could sit on top of this.
+ *
+ * Dependency direction is one-way: this observes the DOM the layout produces; it does not depend on
+ * the `LayoutController`, and slot ids are plain strings, so it works with any slotted layout.
+ */
+
+/** A slot identifier. Plain string so the module is layout-agnostic (e.g. any `SlotName`). */
+export type SlotId = string;
+
+/**
+ * How much of a slot another slot must overlap before it counts as "covered", as a fraction of the
+ * covered slot's own area. Range `0`–`1`: `0` = any overlap at all counts, `1` = the slot must be
+ * completely covered. `0.6` means another slot has to sit over **≥ 60%** of this slot's area (a
+ * side-by-side neighbour that only touches an edge overlaps ~0% and does not count).
+ */
+const COVER_THRESHOLD = 0.6;
+/**
+ * Smallest rendered area, in CSS pixels² (`width × height`), at which a slot still counts as
+ * visible. At or above `1` px² it can be covered/uncovered normally; **below** `1` px² — i.e. a
+ * slot collapsed to (almost) nothing, `width`/`height` of 0, or `display: none` — it is treated as
+ * obscured outright, with no overlap needed. (1 px² is effectively "zero"; it's a small epsilon to
+ * avoid floating-point noise, not a meaningful size.)
+ */
+const MIN_VISIBLE_AREA = 1;
+
+export type SlotCoverage = {
+ /** slot id -> whether it is currently obscured (collapsed, or covered by another slot). */
+ obscured: Record;
+};
+
+/** What a consumer should do with a pending "reveal this slot" intent, given current coverage. */
+export type RevealAction =
+ /** The target hasn't been measured yet (e.g. its view just became active) — do nothing, wait for
+ * the next coverage update. */
+ | 'wait'
+ /** The target is measured and obscured — reveal it (the consumer releases whatever covers it). */
+ | 'act'
+ /** Nothing to do (no intent, or the target is measured and already visible) — clear the intent. */
+ | 'clear';
+
+/**
+ * Decide what to do about a pending reveal intent. This is the core of coverage-driven reveal: it
+ * waits until the target slot has actually been measured, then tells the consumer to reveal it only
+ * if it's obscured, and otherwise to settle. Pure and synchronous so it can be unit-tested and
+ * called from an effect keyed on the reactive `coverage`.
+ */
+export const resolveRevealAction = (
+ revealSlot: SlotId | undefined,
+ coverage: SlotCoverage,
+): RevealAction => {
+ if (revealSlot === undefined) return 'clear';
+ if (!(revealSlot in coverage.obscured)) return 'wait';
+ return coverage.obscured[revealSlot] ? 'act' : 'clear';
+};
+
+export type SlotGeometryContextValue = {
+ /** Clears a pending reveal intent (see {@link SlotGeometryContextValue.requestReveal}). */
+ clearReveal: () => void;
+ coverage: SlotCoverage;
+ /**
+ * Live, synchronous obscured check — measures the DOM at call time, so it is safe to call from
+ * event handlers where the reactive `coverage` snapshot might lag a layout change.
+ */
+ isObscured: (slot: SlotId) => boolean;
+ register: (slot: SlotId, element: HTMLElement | null) => void;
+ /**
+ * Record a one-shot intent to reveal `slot` after navigation. A consumer pairs this with
+ * `resolveRevealAction(revealSlot, coverage)` in an effect to release whatever covers the slot
+ * once it has been measured — the deferred, coverage-conditional reveal used across a view switch.
+ */
+ requestReveal: (slot: SlotId) => void;
+ /** The slot a consumer asked to reveal, or `undefined`. Reactive; resolve with `resolveRevealAction`. */
+ revealSlot: SlotId | undefined;
+};
+
+const noopContextValue: SlotGeometryContextValue = {
+ clearReveal: () => undefined,
+ coverage: { obscured: {} },
+ isObscured: () => false,
+ register: () => undefined,
+ requestReveal: () => undefined,
+ revealSlot: undefined,
+};
+
+const SlotGeometryContext = createContext(noopContextValue);
+
+const intersectionArea = (a: DOMRect, b: DOMRect) => {
+ const width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
+ const height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
+ return width * height;
+};
+
+/**
+ * @internal Exported for unit tests. A target is obscured when its own rendered box is ~0 area
+ * (collapsed/hidden) or another element covers at least `COVER_THRESHOLD` of it.
+ */
+export const measureObscured = (target: HTMLElement, others: HTMLElement[]) => {
+ const rect = target.getBoundingClientRect();
+ const area = rect.width * rect.height;
+ if (area < MIN_VISIBLE_AREA) return true;
+ let maxCoveredFraction = 0;
+ for (const other of others) {
+ const fraction = intersectionArea(rect, other.getBoundingClientRect()) / area;
+ if (fraction > maxCoveredFraction) maxCoveredFraction = fraction;
+ }
+ return maxCoveredFraction >= COVER_THRESHOLD;
+};
+
+const coverageChanged = (previous: SlotCoverage, next: Record) => {
+ const keys = new Set([...Object.keys(previous.obscured), ...Object.keys(next)]);
+ for (const key of keys) {
+ if (previous.obscured[key] !== next[key]) return true;
+ }
+ return false;
+};
+
+export const SlotGeometryProvider = ({ children }: PropsWithChildren) => {
+ const elementsRef = useRef