diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 28e155a0a..47c6ae538 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -150,7 +150,7 @@ code '\b(HeaderStartContent|HeaderEndContent)\b|<(Channel|Thread)Header\b' # §9 channel state and actions code '\b(ChannelStateContext|ChannelStateContextValue|ChannelStateProvider|useChannelStateContext|ChannelActionContext|ChannelActionContextValue|ChannelActionProvider|useChannelActionContext|ChannelNotifications|MarkReadWrapperOptions|RetrySendMessage|TypingContext|TypingContextValue|TypingProvider|useTypingContext|ChannelUnreadUiState|channelUnreadUiState|setChannelUnreadUiState|channelConfig|WatcherState)\b|getConfig\(|\.(read|typing|members|watcher|ownCapabilities|mutedUsers)Store\b|\.disconnected\b|\.(hasNext|hasPrev)\b' # §10 component overrides, context hook arguments, message actions, channel members header actions -code '\bMessage=\{|\b(Message|ThreadHeader|HeaderStartContent|ChannelListUI|LoadingErrorIndicator):|use[A-Za-z]+Context\([^)]|\b(messageActions|MESSAGE_ACTIONS|OPTIONAL_MESSAGE_ACTIONS|getMessageActions|MessageActionsArray|Capabilities|actionsEnabled|canEdit|messageActionSet|disableBaseMessageActionSetFilter|headerActionSet|HeaderActionsMenuTrigger|DefaultHeaderActionsMenuTrigger|ChannelMembersHeaderActionsMenuTriggerProps|ChannelMembersHeaderActionComponentProps|ChannelMembersHeaderActionItem|ChannelMembersHeaderActionPlacement|defaultChannelMembersHeaderActionSet)\b' +code '\bMessage=\{|\b(Message|ThreadHeader|HeaderStartContent|HeaderEndContent|ChannelListUI|LoadingErrorIndicator):|use[A-Za-z]+Context\([^)]|\b(messageActions|MESSAGE_ACTIONS|OPTIONAL_MESSAGE_ACTIONS|getMessageActions|MessageActionsArray|Capabilities|actionsEnabled|canEdit|messageActionSet|disableBaseMessageActionSetFilter|headerActionSet|HeaderActionsMenuTrigger|DefaultHeaderActionsMenuTrigger|ChannelMembersHeaderActionsMenuTriggerProps|ChannelMembersHeaderActionComponentProps|ChannelMembersHeaderActionItem|ChannelMembersHeaderActionPlacement|defaultChannelMembersHeaderActionSet)\b' # §11 message lists code '\bmessages=\{|\b(loadMore|loadMoreNewer|hasMore|hasMoreNewer|loadingMoreNewer|jumpToLatestMessage|messageLimit|highlightedMessageId|threadList|disableDateSeparator|renderMessages|RenderMessagesOptions|getIsFirstUnreadMessage|additionalVirtuosoProps|headerPosition|insertIntro|DateSeparatorMessage|useEnrichedMessages|useNewMessageNotification|useScrollLocationLogic|areMessagePropsEqual|useMessageListScrollManager|NewMessageNotification|ScrollToLatestMessageButton|InfiniteScrollPaginator|loadNextDebounceMs|loadMoreDebounceMs|internalInfiniteScrollProps|noGroupByUser|shouldGroupByUser|groupStyles|defaultItemHeight|overscan|scrollSeekPlaceHolder)\b|\bhead=\{' # §12 messages @@ -226,6 +226,7 @@ Old → new for the most common changes. Ctrl-F the v14 name; the section has th | `Thread` `additionalMessageListProps` and the other `additional*Props` | Props on the children you render | [7](#thread-takes-a-thread-prop-and-renders-its-children) | | `ThreadHeaderProps.closeThread` / `.thread` | `useCloseThread()` / `useThreadContext()` | [7](#threadheader-lost-closethread-and-thread) | | `HeaderStartContent` override | `StartContent` prop on `ChannelHeader` / `ThreadHeader` | [8](#8-channel-and-thread-headers) | +| `HeaderEndContent` override | `EndContent` prop on `ChannelListHeader` / `ThreadListHeader`, through their override keys | [8](#list-headers-headerendcontent-becomes-an-endcontent-prop) | | `useChannelStateContext()` | `useChannel()`, then `useStateStore(channel.messagePaginator.state, selector)` or `useStateStore(channel.state, selector)` | [9](#channelstatecontext-and-channelactioncontext-are-removed) | | `loadMore()` / `loadMoreNewer()` / `hasMore` / `hasMoreNewer` | `messagePaginator.toTail()` / `.toHead()` / `.hasMoreTail` / `.hasMoreHead` | [9](#channelstatecontext-and-channelactioncontext-are-removed) | | `jumpToMessage(id)` / `jumpToLatestMessage()` / `jumpToFirstUnreadMessage()` | `messagePaginator.jumpToMessage(id)` / `.jumpToTheLatestMessage()` / `.jumpToTheFirstUnreadMessage()` | [9](#channelstatecontext-and-channelactioncontext-are-removed) | @@ -278,52 +279,52 @@ Old → new for the most common changes. Ctrl-F the v14 name; the section has th These compile cleanly, or are ignored in plain JS, and change behavior at runtime. Check each one explicitly; the absence of an error does not mean the app is migrated. **Detect** lists `rg` patterns (run them with the `code` / `styles` helpers from [Detection](#detection)) or what to review; a row with no hit and nothing to review does not apply. -| Symptom | Cause | Fix | Detect | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Clicking a channel-list row, a search result, a thread-list row or a message's reply button does nothing; no row shows active | Selection and thread opening go through workspace navigation, whose default outside `ChatView` is a no-op (it logs a one-time console warning per operation) | Use `ChatView`, or provide `WorkspaceNavigationProvider` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | `` in the thread ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)) | ``, the channel list or thread list fills the view, the channel or thread beside it is not visible and the page scrolls horizontally | `--str-chat__channel-list-width` and `--str-chat__thread-list-width` default to `100%`, and the lists do not shrink | Set both widths on the `ChatView` root ([section 19](#panel-layout)) | `views=` with `ChannelNavigation` / `ChannelList` / `ThreadList`, and no `--str-chat__channel-list-width` (`styles`) | -| A channel name or image renders empty | `name` / `image` are custom data in `stream-chat` v10: `channel.data.custom.name` | Read `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)) | `.data?.name`, `.data.name`, `.data?.image` | -| A held channel throws "pending disposal" after logout or a user switch | `disconnectUser()` disposes the user's channels | Drop held channels on disconnect ([section 4](#without-chatview)) | `disconnectUser`, `useState` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | `` misses messages sent while the client was offline, or a channel reloads twice after a reconnect | The client reloads only activated channels after a reconnect, and v14's `Channel` no longer reloads on `connection.recovered` | Call `channel.activate()` in a custom surface; remove app-side reloads on `connection.recovered` ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | `connection.recovered`, `.reload(`, custom channel surfaces without `` in the thread ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)) | ``, the channel list or thread list fills the view, the channel or thread beside it is not visible and the page scrolls horizontally | `--str-chat__channel-list-width` and `--str-chat__thread-list-width` default to `100%`, and the lists do not shrink | Set both widths on the `ChatView` root ([section 19](#panel-layout)) | `views=` with `ChannelNavigation` / `ChannelList` / `ThreadList`, and no `--str-chat__channel-list-width` (`styles`) | +| A channel name or image renders empty | `name` / `image` are custom data in `stream-chat` v10: `channel.data.custom.name` | Read `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)) | `.data?.name`, `.data.name`, `.data?.image` | +| A held channel throws "pending disposal" after logout or a user switch | `disconnectUser()` disposes the user's channels | Drop held channels on disconnect ([section 4](#without-chatview)) | `disconnectUser`, `useState` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | `` misses messages sent while the client was offline, or a channel reloads twice after a reconnect | The client reloads only activated channels after a reconnect, and v14's `Channel` no longer reloads on `connection.recovered` | Call `channel.activate()` in a custom surface; remove app-side reloads on `connection.recovered` ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | `connection.recovered`, `.reload(`, custom channel surfaces without `` rendered `` aro - The buttons act on the panel they render in. A layout that renders its own panels declares them with `WorkspacePanelProvider`. Without a workspace navigation (outside `ChatView`, with no provider), nothing can be dismissed, so they render nothing. - Passing a component replaces the default button. Render `WorkspaceNavigationBackButton` / `WorkspaceNavigationCloseButton` inside yours to keep it. - `ChannelHeader` also takes `Title`, which replaces only the title and receives `{ channel, displayTitle }` (`ChannelHeaderTitleProps`; the default is `ChannelHeaderTitle`). Render the title as a button there instead of rebuilding the header. `Subtitle` likewise replaces only the subtitle (default `ChannelHeaderSubtitle`). A header you build yourself keeps the default subtitle by rendering `ChannelHeaderSubtitle` (Online/Offline for a direct message, member and online counts otherwise, the typing indicator while someone types), or reads the status text with `useChannelHeaderOnlineStatus()`. -- `ComponentContext.HeaderEndContent` is unchanged and still feeds `ChannelListHeader` / `ThreadListHeader`, but `ChannelListHeader` shows it only while a channel is open in the workspace. +- The channel-list and thread-list headers take `StartContent` / `EndContent` props too: [below](#list-headers-headerendcontent-becomes-an-endcontent-prop). ```tsx // v14 @@ -1669,6 +1669,73 @@ export const ChannelPanel = ({ channel }: { channel: StreamChannel }) => ( ); ``` +### List headers: `HeaderEndContent` becomes an `EndContent` prop + +`ComponentContext.HeaderEndContent` is removed. Written inline in `overrides`, the key is a compile error (`TS2353` / `TS2561`). In JS, or in an `overrides` object declared as a variable that also holds a valid key, it is ignored without an error and the content disappears. In v14 one component filled the end of both list headers, and showed only while a channel was open (channel list) or a thread was open (thread list). + +`ChannelListHeader` and `ThreadListHeader` now take the content as props, like `ChannelHeader` and `ThreadHeader` do. Neither prop has a default, and both render whenever they are set: + +| Prop | Shows | +| -------------- | ---------------------- | +| `StartContent` | Before the title (new) | +| `EndContent` | After the title | + +Where you pass the props depends on who renders the header: + +- **The SDK renders it** (`ChannelNavigation` renders `ChannelListHeader`, `ThreadList` renders `ThreadListHeader`): pass the props through the header's override key. Register a component that renders the default header with your content under `ChannelListHeader` or `ThreadListHeader` (a new key). +- **You render it** (for example `ChannelListHeader` above a standalone `ChannelList`, in your own navigation): pass `StartContent` / `EndContent` to it directly. The override key does not reach a header you render yourself. + +Both headers are exported. + +To keep the v14 behavior of showing the content only next to an open channel or thread (a sidebar toggle, for example), make the content return `null` otherwise: read `openChannels` / `openThreads` from `useWorkspaceNavigation()`. `ChatView` (or your own `WorkspaceNavigationProvider`, [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) fills these lists; without one they stay empty, so gate on your own selection state instead. + +```tsx +// v14 +{/* … */} +``` + +```tsx +import { + ChannelListHeader, + ThreadListHeader, + useWorkspaceNavigation, + WithComponents, +} from 'stream-chat-react'; + +// `SidebarToggle` stands for a control of your own. +const SidebarToggle = () => ; + +// Shown only while a channel is open, as in v14. +const SidebarToggleWhileChannelOpen = () => { + const { openChannels } = useWorkspaceNavigation(); + return openChannels.length > 0 ? : null; +}; + +// Shown only while a thread is open, as in v14. +const SidebarToggleWhileThreadOpen = () => { + const { openThreads } = useWorkspaceNavigation(); + return openThreads.length > 0 ? : null; +}; + +const ChannelListHeaderWithToggle = () => ( + +); + +const ThreadListHeaderWithToggle = () => ( + +); + +// Declared once, so WithComponents gets the same object on every render. +const overrides = { + ChannelListHeader: ChannelListHeaderWithToggle, + ThreadListHeader: ThreadListHeaderWithToggle, +}; + +export const App = ({ children }: { children: React.ReactNode }) => ( + {children} +); +``` + --- ## 9. Reading channel state @@ -1893,15 +1960,16 @@ These are `stream-chat` changes, documented in its own migration notes. They are ### Removed `ComponentContext` slots -A stale key in `WithComponents overrides` is a compile error (`TS2353` / `TS2561`). In plain JS or behind `any` it is ignored with no warning, and the default UI renders (or nothing, for `HeaderStartContent`). +A stale key written inline in `WithComponents overrides` is a compile error (`TS2353` / `TS2561`). In plain JS, behind `any`, or in an `overrides` object declared as a variable that also holds a valid key (TypeScript checks unknown keys only on object literals), it is ignored with no warning, and the default UI renders (or nothing, for `HeaderStartContent` and `HeaderEndContent`). -| Removed slot | v15 | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Message` | `MessageUI` ([below](#message-ui-overrides-consolidate-on-the-messageui-slot)) | -| `ThreadHeader` | Render `` (or your own) as a child of `` ([section 7](#threadheader-lost-closethread-and-thread)) | -| `HeaderStartContent` | `StartContent` on `ChannelHeader` / `ThreadHeader` ([section 8](#8-channel-and-thread-headers)) | -| `ChannelListUI` | The list slots ([section 6](#channellistui-is-removed-use-the-list-slots)) | -| `LoadingErrorIndicator` | Render the exported `LoadingErrorIndicator` yourself, around `Channel` ([section 5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state)) | +| Removed slot | v15 | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Message` | `MessageUI` ([below](#message-ui-overrides-consolidate-on-the-messageui-slot)) | +| `ThreadHeader` | Render `` (or your own) as a child of `` ([section 7](#threadheader-lost-closethread-and-thread)) | +| `HeaderStartContent` | `StartContent` on `ChannelHeader` / `ThreadHeader` ([section 8](#8-channel-and-thread-headers)) | +| `HeaderEndContent` | `EndContent` on `ChannelListHeader` / `ThreadListHeader`, through the `ChannelListHeader` / `ThreadListHeader` keys ([section 8](#list-headers-headerendcontent-becomes-an-endcontent-prop)) | +| `ChannelListUI` | The list slots ([section 6](#channellistui-is-removed-use-the-list-slots)) | +| `LoadingErrorIndicator` | Render the exported `LoadingErrorIndicator` yourself, around `Channel` ([section 5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state)) | TypeScript suggests `ThreadHead` for a stale `ThreadHeader` key. Do not follow it: `ThreadHead` is the thread's parent-message slot ([section 7](#threadheader-lost-closethread-and-thread)). @@ -3516,7 +3584,7 @@ Run after every section is applied. 2. **Leftover identifiers.** Every v14 name below was removed or renamed. In a plain-JS app this grep is the only thing that finds them. It must print nothing outside comments, changelogs and app identifiers that only share the name (record those in the report). Uses the `code` / `styles` helpers from [Detection](#detection). ```bash - code '\b(WindowProps|UploadIcon|BinIcon|PauseIcon|PlayIcon|ChannelStateContext|ChannelStateContextValue|ChannelStateProvider|useChannelStateContext|ChannelActionContext|ChannelActionContextValue|ChannelActionProvider|useChannelActionContext|ChannelNotifications|MarkReadWrapperOptions|RetrySendMessage|TypingContext|TypingContextValue|TypingProvider|useTypingContext|ChannelUnreadUiState|channelUnreadUiState|setChannelUnreadUiState|updateChannelUiUnreadState|useThreadsViewContext|ThreadsViewContextValue|ThreadAdapter|activeChatView|setActiveChatView|setActiveChannel|setActiveChannelOnMount|customActiveChannel|channelsQueryState|latestMessageDatesByChannels|useChannelEditMessageHandler|useChannelMentionsHandler|ChannelListUI|ChannelListUIProps|usePaginatedChannels|useConnectionRecoveredListener|CustomQueryChannelParams|CustomQueryChannelsFn|EffectiveQueryParams|moveChannelUpwards|findLastPinnedChannelIndex|extractSortValue|isChannelPinned|isChannelArchived|shouldConsiderPinnedChannels|shouldConsiderArchivedChannels|allowNewMessagesFromUnfilteredChannels|lockChannelOrder|showChannelSearch|sendChannelsToList|renderChannels|channelRenderFilterFn|customQueryChannels|recoveryThrottleIntervalMs|onMessageNewHandler|channelUpdateCount|useLegacyThreadContext|additionalMessageComposerProps|additionalMessageListProps|additionalVirtualizedMessageListProps|additionalParentMessageProps|enableDateSeparator|disableDateSeparator|HeaderStartContent|MESSAGE_ACTIONS|OPTIONAL_MESSAGE_ACTIONS|getMessageActions|MessageActionsArray|actionsEnabled|messageIsUnread|initializeOnMount|channelQueryOptions|markReadOnMount|activeUnreadHandler|skipMessageDataMemoization|allowConcurrentAudioPlayback|allowConcurrentPlayback|doSendMessageRequest|doUpdateMessageRequest|doDeleteMessageRequest|doMarkReadRequest|overrideSubmitHandler|preventClearingOnUnmount|loadMoreNewer|loadMoreThread|loadingMoreNewer|jumpToLatestMessage|jumpToFirstUnreadMessage|highlightedMessageId|threadSuppressAutoscroll|loadingMoreForJumpToChannelMessage|messageLimit|hasPendingUploadState|recoverStateOnReconnect|wsUrlParams|isLocalUnreadCountEnabled|StreamChatOptions|isLanguageSupported|SupportedTranslations|DateFormatterOptions|getTranslators|registerSetLanguageCallback|getAvailableLanguages|geti18Instance|deTranslations|enTranslations|esTranslations|frTranslations|hiTranslations|itTranslations|jaTranslations|koTranslations|nlTranslations|ptTranslations|ruTranslations|trTranslations|ChannelSort|UserSort|ReactionSort|WatcherState|unBlockUser|ReminderResponse|CommandResponse|DefaultCommandData|CustomCommandData|chatContainer|useUserRole|disableQuotedMessages|initialMessage|MentionedUserEventHandler|mentionedUser|noGroupByUser|shouldGroupByUser|defaultItemHeight|scrollSeekPlaceHolder|endOfGroup|firstOfGroup|groupedByUser)\b| { - Replacement: - `Message` -> the `MessageUI` slot. `VirtualMessage` still overrides inside `VirtualizedMessageList` only. The `Message` _props_ on the lists and `Thread` belong to the message area's entry. - `ThreadHeader` -> render `` (or your own header) as a child of ``. `Thread` renders children and no header of its own. - - `HeaderStartContent` -> the `StartContent` prop on `ChannelHeader` / `ThreadHeader`, which defaults to `WorkspaceNavigationBackButton`. `EndContent` defaults to `WorkspaceNavigationCloseButton`. `HeaderEndContent` stays on `ComponentContext` for the list headers. + - `HeaderStartContent` -> the `StartContent` prop on `ChannelHeader` / `ThreadHeader`, which defaults to `WorkspaceNavigationBackButton`. `EndContent` defaults to `WorkspaceNavigationCloseButton`. `HeaderEndContent` -> the `EndContent` prop on `ChannelListHeader` / `ThreadListHeader` (BC-099). - `ChannelListUI` -> no wrapper slot. `ChannelList` renders an `InfiniteScrollPaginator` and reads the new slots `EmptyListIndicator`, `EndReachedIndicator`, `FirstPageLoadingIndicator`, `ListItem` and `LoadingNextPageIndicator` from `ComponentContext`. `ThreadList` reads `FirstPageLoadingIndicator` too, and both read the new `ListLoadErrorIndicator`; only `ChannelList` reads the others today. - Evidence: - `release-v14:src/context/ComponentContext.tsx:129` `ChannelListUI?`, `:179` `Message?` (`@deprecated use MessageUI`), `:288` `ThreadHeader?`, `:305` `HeaderStartContent?` - - `master:src/context/ComponentContext.tsx` none of the four; `:187` `MessageUI?`, `:294` `ThreadHead?`, `:309` `HeaderEndContent?`, `:317` `VirtualMessage?`, `:88-102` the new list slots + - `master:src/context/ComponentContext.tsx` none of the four; `:187` `MessageUI?`, `:294` `ThreadHead?`, `:317` `VirtualMessage?`, `:88-102` the new list slots - `master:src/components/ChannelHeader/ChannelHeader.tsx:58,64,77,79` and `master:src/components/Thread/ThreadHeader.tsx:85,91,97,99` `StartContent` / `EndContent` props with the workspace-navigation defaults - `master:src/components/Thread/Thread.tsx:41-56` JSDoc: `…` - `master:src/components/ChannelList/ChannelList.tsx:48-54` reads `EmptyListIndicator`, `EndReachedIndicator`, `FirstPageLoadingIndicator`, `ListItem`, `LoadingNextPageIndicator` from `useComponentContext()` @@ -1060,7 +1061,7 @@ A threads view is declared the same way (a `{ id: 'threads', slots: [...] }` lay - Area: navigation - User impact: - compile error reading `setActiveChannel` / `channel` from `useChatContext()` (and from the return of `useChat()`); `undefined` in JS. - - silent behavior change: without `ChatView` (or your own `WorkspaceNavigationProvider`), clicking a `ChannelList` row, a channel/message/user search result, a `ThreadList` row, a message's reply-count button, "also sent in channel", or the channel-detail "send direct message" / pinned-message entry does nothing, and no list row renders as active (`ChannelListItem` derives `active` from `isChannelActive(cid)`, `ChannelListHeader` only shows `HeaderEndContent` while `openChannels` is non-empty). + - silent behavior change: without `ChatView` (or your own `WorkspaceNavigationProvider`), clicking a `ChannelList` row, a channel/message/user search result, a `ThreadList` row, a message's reply-count button, "also sent in channel", or the channel-detail "send direct message" / pinned-message entry does nothing, and no list row renders as active (`ChannelListItem` derives `active` from `isChannelActive(cid)`). - with no navigation provided at all, each operation (`openChannel`, `openThread`, `closeChannel`, `closeThread`, `goBack`) logs a one-time console warning that nothing happened; `defaultWorkspaceNavigation` itself stays silent, so a provider built by spreading it does not warn. - `ChannelListItem` / `ChannelListItemUI` no longer accept `setActiveChannel` / `watchers` (BC-033). - Old API: @@ -1998,10 +1999,10 @@ Guide check: `ai-migration-v14-v15.md` "`ChannelListItem` `getLatestMessagePrevi - User impact: compile error on a `HeaderStartContent` override (`TS2561`); in JS it is ignored and the custom content (often a sidebar toggle) disappears. Visible change: by default `ChannelHeader` / `ThreadHeader` render `WorkspaceNavigationBackButton` at the start and `WorkspaceNavigationCloseButton` at the end; both render nothing outside a workspace navigation that reports the panel as stacked / dismissable. - Old API: - `ComponentContextValue.HeaderStartContent` -- Replacement: `` and the same on `ThreadHeader`. Passing a component replaces the default button; render `WorkspaceNavigationBackButton` / `WorkspaceNavigationCloseButton` (new exports) inside yours to keep it. `ComponentContext.HeaderEndContent` is unchanged and still feeds `ChannelListHeader` / `ThreadListHeader` (but `ChannelListHeader` shows it only while a channel is open in the workspace). +- Replacement: `` and the same on `ThreadHeader`. Passing a component replaces the default button; render `WorkspaceNavigationBackButton` / `WorkspaceNavigationCloseButton` (new exports) inside yours to keep it. `ComponentContext.HeaderEndContent` was removed too; the list headers take `StartContent` / `EndContent` props (BC-099). - Evidence: - `release-v14:src/context/ComponentContext.tsx:305`; `release-v14:src/components/ChannelHeader/ChannelHeader.tsx:52,61-63` - - `master:src/components/ChannelHeader/ChannelHeader.tsx:50-69,74-110`; `master:src/components/Thread/ThreadHeader.tsx:79-100`; `master:src/components/ChannelList/ChannelListHeader.tsx` (`hasActiveChannel = useWorkspaceNavigation().openChannels.length > 0`) + - `master:src/components/ChannelHeader/ChannelHeader.tsx:50-69,74-110`; `master:src/components/Thread/ThreadHeader.tsx:79-100` - commit: `66557d63b feat!: port the tutorial alignment (#3251) to v15, with panel-aware workspace navigation` (PR #3301) - Docs impact: - `02-ui-components/04-channel/02-channel_header.md`, `02-ui-components/04-channel/05-component_context.md`, `02-ui-components/03-chat/01-chat.md`, `03-ui-cookbook/12-collapsible-sidebar.md`, `06-release-guides/01-upgrade-to-v14.md` @@ -2247,6 +2248,32 @@ const CustomThreadHeader = () => { - `03-ui-cookbook/07-notifications/01-system-notifications.md`, `04-guides/13-notifications.md` - Example needed: no + + +### BC-099: `ComponentContext.HeaderEndContent` was removed; `ChannelListHeader` and `ThreadListHeader` take `StartContent` / `EndContent` props, rendered whenever set + +- Status: confirmed +- Area: channel list header / thread list header / ComponentContext +- User impact: + - compile error on a `HeaderEndContent` key written inline in `overrides` (`TS2353` / `TS2561`). In JS, or in an `overrides` object declared as a variable that also holds a valid key, the key is ignored without an error and the content disappears from both list headers. + - behavior change: v14 rendered `HeaderEndContent` in `ChannelListHeader` only while a channel was active, and in `ThreadListHeader` only while a thread was active. The `EndContent` prop renders whenever it is set, so content that only makes sense next to an open channel or thread (a sidebar toggle) has to return `null` itself. + - one component no longer fills both headers: pass it to each header that should show it. +- Old API: + - `ComponentContextValue.HeaderEndContent`, rendered by `ChannelListHeader` (gated on the active channel) and `ThreadListHeader` (gated on the active thread) +- Replacement: + - `ChannelListHeader` and `ThreadListHeader` take `StartContent` (before the title) and `EndContent` (after the title), as `ChannelHeader` / `ThreadHeader` do. Neither has a default. Types: `ChannelListHeaderProps`, `ThreadListHeaderProps`. + - where the SDK renders the header, the props go through the header's override key: register a component that renders the default header with them. `ChannelListHeader` is read by `ChannelNavigation`; `ThreadListHeader` is a new key, read by `ThreadList`. `ThreadListHeader` is now exported. + - where the app renders the header itself (for example `ChannelListHeader` above a standalone `ChannelList`), it passes the props directly; the override key does not reach that header. + - to keep the v14 gate, read `useWorkspaceNavigation().openChannels` / `.openThreads` in the content component and return `null` while it is empty. `ChatView` or an app's own `WorkspaceNavigationProvider` fills these lists; without one they stay empty, so an app without either gates on its own selection state. +- Evidence: + - `release-v14:src/components/ChannelList/ChannelListHeader.tsx:15` `{channel && HeaderEndContent && }`; `release-v14:src/components/Threads/ThreadList/ThreadListHeader.tsx:12` `{activeThread && HeaderEndContent && }`; `release-v14:src/context/ComponentContext.tsx:303` + - `master:src/components/ChannelList/ChannelListHeader.tsx` and `master:src/components/Threads/ThreadList/ThreadListHeader.tsx` (`StartContent` / `EndContent` props); `master:src/components/Threads/ThreadList/ThreadList.tsx` reads `ThreadListHeader` from `useComponentContext()`; `master:src/context/ComponentContext.tsx` `ThreadListHeader?` + - ticket: REACT-1449 + - examples: `examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx` passes `EndContent={SidebarToggleWhileChannelOpen}` to `ChannelListHeader`; `examples/vite/src/App.tsx` registers `ThreadListHeaderWithSidebarToggle` (`examples/vite/src/Sidebar/HeaderContent.tsx`) as `ThreadListHeader` +- Docs impact: + - `03-ui/03-components/01-channel-list-ui.md`, `03-ui/03-components/08-thread-list-ui.md`, `03-ui/03-components/12-overriding-components.md`, `06-component-reference/02-channel/04-componentcontext.md`, `06-component-reference/07-thread-list/01-threadlist.md`, `05-ui-cookbooks/10-layout/04-collapsible-sidebar.md`, `05-ui-cookbooks/03-search/03-mentions-inbox.md`, `08-release-guides/01-upgrade-to-v15.md` +- Example needed: yes (the guide section has one) + --- ## Messages and message lists diff --git a/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx b/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx index 7a796b691..0f32984ab 100644 --- a/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx +++ b/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx @@ -1,10 +1,12 @@ import type { PropsWithChildren } from 'react'; import { ChannelHeader, + ChannelListHeader, ChannelNavigation, MessageComposer, MessageList, ThreadHeader, + useWorkspaceNavigation, WorkspaceNavigationBackButton, } from 'stream-chat-react'; import { ChannelSlot, ThreadSlot } from 'stream-chat-react/slot-layout'; @@ -18,8 +20,20 @@ export const layouts: LayoutDescriptor[] = [ { id: 'channels', slots: ['channel', 'thread'] }, ]; +// The toggle, only while a channel is open beside the list. With nothing open, collapsing the +// sidebar would leave an empty screen. +const SidebarToggleWhileChannelOpen = () => { + const { openChannels } = useWorkspaceNavigation(); + return openChannels.length > 0 ? : null; +}; + +// The default channel list header, with the toggle at its end. +const ChannelListHeaderWithToggle = () => ( + +); + // Declared once, so WithComponents gets the same object on every render. -export const overrides = { HeaderEndContent: SidebarToggle }; +export const overrides = { ChannelListHeader: ChannelListHeaderWithToggle }; // The toggle, then the back button the header shows by default. const ChannelHeaderStart = () => ( diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index db9fc3f98..bef64b8ba 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -94,7 +94,7 @@ import { } from './CustomMessageUi'; import { ConfigurableMessageActions } from './CustomMessageActions'; import { InlineEditableMessage } from './InlineEditMessage'; -import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; +import { ThreadListHeaderWithSidebarToggle } from './Sidebar/HeaderContent.tsx'; import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx'; import { ConfigurableComposerPreviews, @@ -694,7 +694,7 @@ const App = () => { MessageReactions: CustomMessageReactions, reactionOptions: newReactionOptions, Search: CustomChannelSearch, - HeaderEndContent: SidebarToggle, + ThreadListHeader: ThreadListHeaderWithSidebarToggle, MessageActions: ConfigurableMessageActions, AttachmentSelector: CommandModeAttachmentSelector, MessageBubbleContent: ConfigurableMessageBubbleContent, diff --git a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx index a6fd52aed..53a50fc94 100644 --- a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx +++ b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx @@ -25,6 +25,7 @@ import { useStateStore, } from 'stream-chat-react'; +import { SidebarToggleWhileChannelOpen } from '../Sidebar/HeaderContent.tsx'; import { ScrollToActiveChannelButton } from './ScrollToActiveChannelButton'; // Human-readable labels for each channel list, keyed by the paginator id set up in App.tsx. @@ -221,7 +222,7 @@ export const SwitchableChannelNavigation = () => { return (
- + {!isActive && activePaginator && ( // Expose the active paginator through ChannelListContext (as the SDK's ChannelLists diff --git a/examples/vite/src/Sidebar/HeaderContent.tsx b/examples/vite/src/Sidebar/HeaderContent.tsx index 607a0916c..30e977db9 100644 --- a/examples/vite/src/Sidebar/HeaderContent.tsx +++ b/examples/vite/src/Sidebar/HeaderContent.tsx @@ -1,4 +1,8 @@ -import { WorkspaceNavigationBackButton } from 'stream-chat-react'; +import { + ThreadListHeader, + useWorkspaceNavigation, + WorkspaceNavigationBackButton, +} from 'stream-chat-react'; import { SidebarToggle } from './SidebarToggle.tsx'; @@ -10,3 +14,23 @@ export const HeaderStartWithSidebarToggle = () => ( ); + +/** The sidebar toggle while a channel is open beside the channel list. With nothing open, + * collapsing the sidebar would leave an empty screen. */ +export const SidebarToggleWhileChannelOpen = () => { + const { openChannels } = useWorkspaceNavigation(); + return openChannels.length > 0 ? : null; +}; + +/** The sidebar toggle while a thread is open beside the thread list. With nothing open, + * collapsing the sidebar would leave an empty screen. */ +export const SidebarToggleWhileThreadOpen = () => { + const { openThreads } = useWorkspaceNavigation(); + return openThreads.length > 0 ? : null; +}; + +/** The thread list header, registered as the `ThreadListHeader` override, with the sidebar toggle + * at its end. */ +export const ThreadListHeaderWithSidebarToggle = () => ( + +); diff --git a/src/components/ChannelList/ChannelListHeader.tsx b/src/components/ChannelList/ChannelListHeader.tsx index 269beea37..3d80a3347 100644 --- a/src/components/ChannelList/ChannelListHeader.tsx +++ b/src/components/ChannelList/ChannelListHeader.tsx @@ -1,21 +1,26 @@ import React from 'react'; -import { - useComponentContext, - useTranslationContext, - useWorkspaceNavigation, -} from '../../context'; +import { useTranslationContext } from '../../context'; -export const ChannelListHeader = () => { +export type ChannelListHeaderProps = { + /** Rendered at the end of the header, after the title. No default. */ + EndContent?: React.ComponentType; + /** Rendered at the start of the header, before the title. No default. */ + StartContent?: React.ComponentType; +}; + +/** Header of the channel list: the "Chats" title, with optional start and end content. */ +export const ChannelListHeader = ({ + EndContent, + StartContent, +}: ChannelListHeaderProps) => { const { t } = useTranslationContext(); - const { HeaderEndContent } = useComponentContext(); - // `HeaderEndContent` renders only while a channel is open in the workspace. - const hasActiveChannel = useWorkspaceNavigation().openChannels.length > 0; return (
+ {StartContent && }
{t('channelList.header.chats.text', 'Chats')}
- {hasActiveChannel && HeaderEndContent && } + {EndContent && }
); }; diff --git a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx index 7cbd57394..1d60ef201 100644 --- a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx @@ -1,58 +1,50 @@ import React from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import { WithComponents, WorkspaceNavigationProvider } from '../../../context'; +import { WorkspaceNavigationProvider } from '../../../context'; import { TranslationProvider } from '../../../context/TranslationContext'; import { mockTranslationContextValue } from '../../../mock-builders'; +import type { ChannelListHeaderProps } from '../ChannelListHeader'; import { ChannelListHeader } from '../ChannelListHeader'; import { mockT } from '../../../mock-builders/translator'; const t = vi.fn(mockT); -const HeaderEndContent = () =>
; +const EndContent = () =>
; +const StartContent = () =>
; afterEach(cleanup); -// The header reads `openChannels` from the workspace navigation; drive it through the real -// provider. -const renderHeader = ({ - openChannels, - withHeaderEndContent, -}: { - openChannels: unknown[]; - withHeaderEndContent: boolean; -}) => { - const inner = ( - - - - ); - return render( - - {withHeaderEndContent ? ( - {inner} - ) : ( - inner - )} +// Nothing is open in the workspace navigation: the start and end content must not depend on it. +const renderHeader = (props: ChannelListHeaderProps = {}) => + render( + + + + , ); -}; describe('ChannelListHeader', () => { - it('should not render HeaderEndContent when not provided via ComponentContext', () => { - renderHeader({ openChannels: [{}], withHeaderEndContent: false }); - - expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument(); - }); - - it('should render HeaderEndContent when a channel is active', () => { - renderHeader({ openChannels: [{}], withHeaderEndContent: true }); - - expect(screen.getByTestId('sidebar-toggle')).toBeInTheDocument(); + it('should render only the title by default', () => { + const { container } = renderHeader(); + + expect(screen.queryByTestId('header-start')).not.toBeInTheDocument(); + expect(screen.queryByTestId('header-end')).not.toBeInTheDocument(); + expect( + container.querySelector('.str-chat__channel-list__header')?.children, + ).toHaveLength(1); }); - it('should not render HeaderEndContent when no channel is active', () => { - renderHeader({ openChannels: [], withHeaderEndContent: true }); + it('should render StartContent before the title and EndContent after it, without an open channel', () => { + const { container } = renderHeader({ EndContent, StartContent }); - expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument(); + const children = Array.from( + container.querySelector('.str-chat__channel-list__header')?.children ?? [], + ); + expect( + children.map((child) => child.getAttribute('data-testid') ?? child.className), + ).toEqual(['header-start', 'str-chat__channel-list__header__title', 'header-end']); }); }); diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx index 3bbdac179..46204a319 100644 --- a/src/components/Threads/ThreadList/ThreadList.tsx +++ b/src/components/Threads/ThreadList/ThreadList.tsx @@ -24,7 +24,7 @@ import { useTranslationContext, } from '../../../context'; import { useStateStore } from '../../../store'; -import { ThreadListHeader } from './ThreadListHeader'; +import { ThreadListHeader as DefaultThreadListHeader } from './ThreadListHeader'; const noThreads: Thread[] = []; @@ -80,6 +80,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { ListLoadErrorIndicator = DefaultListLoadErrorIndicator, NotificationList: NotificationListFromContext = NotificationList, ThreadListEmptyPlaceholder = DefaultThreadListEmptyPlaceholder, + ThreadListHeader = DefaultThreadListHeader, ThreadListItem = DefaultThreadListItem, ThreadListLoadingIndicator = DefaultThreadListLoadingIndicator, ThreadListUnseenThreadsBanner = DefaultThreadListUnseenThreadsBanner, diff --git a/src/components/Threads/ThreadList/ThreadListHeader.tsx b/src/components/Threads/ThreadList/ThreadListHeader.tsx index cd845a545..57f08358c 100644 --- a/src/components/Threads/ThreadList/ThreadListHeader.tsx +++ b/src/components/Threads/ThreadList/ThreadListHeader.tsx @@ -1,21 +1,23 @@ import React from 'react'; -import { - useComponentContext, - useTranslationContext, - useWorkspaceNavigation, -} from '../../../context'; +import { useTranslationContext } from '../../../context'; -export const ThreadListHeader = () => { +export type ThreadListHeaderProps = { + /** Rendered at the end of the header, after the title. No default. */ + EndContent?: React.ComponentType; + /** Rendered at the start of the header, before the title. No default. */ + StartContent?: React.ComponentType; +}; + +/** Header of the thread list: the "Threads" title, with optional start and end content. */ +export const ThreadListHeader = ({ EndContent, StartContent }: ThreadListHeaderProps) => { const { t } = useTranslationContext(); - const { HeaderEndContent } = useComponentContext(); - // A thread is "active" when one is open in the workspace. - const hasActiveThread = useWorkspaceNavigation().openThreads.length > 0; return (
+ {StartContent && }
{t('common.threads.text', 'Threads')}
- {hasActiveThread && HeaderEndContent && } + {EndContent && }
); }; diff --git a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx index ed022c57f..482f245ad 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx @@ -251,6 +251,17 @@ describe('ThreadList', () => { expect(screen.queryByTestId('loading-channels')).not.toBeInTheDocument(); }); + it('renders the ThreadListHeader override instead of the default header', () => { + mockUseComponentContext.mockReturnValue({ + ThreadListHeader: () =>
, + }); + + render(); + + expect(screen.getByTestId('custom-thread-list-header')).toBeInTheDocument(); + expect(screen.queryByTestId('thread-list-header')).not.toBeInTheDocument(); + }); + it('renders the overrides of the first-load skeleton and the load error', () => { const lastQueryError = new Error('query failed'); const ListLoadErrorIndicator = vi.fn(() =>
); diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx index 8e0c48ef0..0dc3a5ea1 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx @@ -1,58 +1,50 @@ import React from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import { WithComponents, WorkspaceNavigationProvider } from '../../../../context'; +import { WorkspaceNavigationProvider } from '../../../../context'; import { TranslationProvider } from '../../../../context/TranslationContext'; import { mockTranslationContextValue } from '../../../../mock-builders'; +import type { ThreadListHeaderProps } from '../ThreadListHeader'; import { ThreadListHeader } from '../ThreadListHeader'; import { mockT } from '../../../../mock-builders/translator'; const t = vi.fn(mockT); -const HeaderEndContent = () =>
; +const EndContent = () =>
; +const StartContent = () =>
; afterEach(cleanup); -// The header derives "a thread is active" from the workspace navigation's `openThreads`, so the -// tests drive it through the real provider. -const renderHeader = ({ - openThreads, - withHeaderEndContent, -}: { - openThreads: unknown[]; - withHeaderEndContent: boolean; -}) => { - const inner = ( - - - - ); - return render( - - {withHeaderEndContent ? ( - {inner} - ) : ( - inner - )} +// Nothing is open in the workspace navigation: the start and end content must not depend on it. +const renderHeader = (props: ThreadListHeaderProps = {}) => + render( + + + + , ); -}; describe('ThreadListHeader', () => { - it('should not render HeaderEndContent when not provided via ComponentContext', () => { - renderHeader({ openThreads: [{}], withHeaderEndContent: false }); - - expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument(); - }); - - it('should render HeaderEndContent when a thread is active', () => { - renderHeader({ openThreads: [{}], withHeaderEndContent: true }); - - expect(screen.getByTestId('sidebar-toggle')).toBeInTheDocument(); + it('should render only the title by default', () => { + const { container } = renderHeader(); + + expect(screen.queryByTestId('header-start')).not.toBeInTheDocument(); + expect(screen.queryByTestId('header-end')).not.toBeInTheDocument(); + expect( + container.querySelector('.str-chat__thread-list__header')?.children, + ).toHaveLength(1); }); - it('should not render HeaderEndContent when no thread is active', () => { - renderHeader({ openThreads: [], withHeaderEndContent: true }); + it('should render StartContent before the title and EndContent after it, without an open thread', () => { + const { container } = renderHeader({ EndContent, StartContent }); - expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument(); + const children = Array.from( + container.querySelector('.str-chat__thread-list__header')?.children ?? [], + ); + expect( + children.map((child) => child.getAttribute('data-testid') ?? child.className), + ).toEqual(['header-start', 'str-chat__thread-list__header__title', 'header-end']); }); }); diff --git a/src/components/Threads/ThreadList/index.ts b/src/components/Threads/ThreadList/index.ts index 71a4c7df4..9c4a467e1 100644 --- a/src/components/Threads/ThreadList/index.ts +++ b/src/components/Threads/ThreadList/index.ts @@ -1,4 +1,5 @@ export * from './ThreadList'; +export * from './ThreadListHeader'; export * from './ThreadListItem'; export * from './ThreadListItemUI'; export * from './utils.a11y'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 875f72ddb..546608311 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -139,7 +139,10 @@ export type ComponentContextValue = { CalloutDialog?: React.ComponentType; /** Shown instead of an image that fails to load. */ ImagePlaceholder?: React.ComponentType; - /** Header of the `ChannelList`. */ + /** + * Header of the `ChannelList`, rendered without props. To add start or end content, register a + * component that renders `ChannelListHeader` with `StartContent` / `EndContent`. + */ ChannelListHeader?: React.ComponentType; /** Action buttons within `ChannelListItemUI`. */ ChannelListItemActionButtons?: React.ComponentType; @@ -333,6 +336,11 @@ export type ComponentContextValue = { /** The thread's parent message, rendered above the replies in a thread's message list. */ ThreadHead?: React.ComponentType; ThreadListEmptyPlaceholder?: React.ComponentType; + /** + * Header of the `ThreadList`, rendered without props. To add start or end content, register a + * component that renders `ThreadListHeader` with `StartContent` / `EndContent`. + */ + ThreadListHeader?: React.ComponentType; ThreadListItem?: React.ComponentType; ThreadListItemUI?: React.ComponentType; ThreadListLoadingIndicator?: React.ComponentType; @@ -350,11 +358,6 @@ export type ComponentContextValue = { * `UnreadMessagesSeparator`. */ UnreadMessagesNotification?: React.ComponentType; - /** - * Rendered at the end of `ChannelListHeader` while a channel is open, and of `ThreadListHeader` - * while a thread is open. No default. - */ - HeaderEndContent?: React.ComponentType; /** Separates read messages from unread ones. */ UnreadMessagesSeparator?: React.ComponentType; /** Uploaded vs total byte size during an attachment upload, in the composer previews. */