diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 3e65f7dd36..87150e7a5a 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2,7 +2,7 @@ Execution-only guide for a coding agent migrating a third-party app from `stream-chat-react` v14 to v15. It covers the root package and its subpath entry points (`stream-chat-react/slot-layout`, `/slot-geometry`, `/channel-detail`, `/emojis`, `/ai-components`, `/mp3-encoder` and the `css/*` stylesheets). v15 requires `stream-chat` v10, itself a breaking client release, so upgrade both together; this guide covers the `stream-chat` changes a React app usually hits, and the [stream-chat guide](#companion-documents) covers the rest. The i18n runtime now comes from `@stream-io/i18n` and the state store from `@stream-io/state-store`, both installed as dependencies of this package. -Every breaking change in [`v15-breaking-changes.md`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/v15-breaking-changes.md) (`BC-001` to `BC-084`) is covered here. The [index at the end](#index-of-breaking-changes) maps each `BC` entry to its section. +Every breaking change in [`v15-breaking-changes.md`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/v15-breaking-changes.md) (`BC-001` to `BC-095`) is covered here. The [index at the end](#index-of-breaking-changes) maps each `BC` entry to its section. Verified against `master` @ `1c02ba07f` (`stream-chat-react@0.0.0-development`, after the `v15.0.0-rc.3` tag; `stream-chat` peer `^10.0.0-rc.29`, checked against stream-chat-js `release-v10` @ `fdfcb296`), 2026-11-03. @@ -147,9 +147,9 @@ code '\b(ChannelStateContext|ChannelStateContextValue|ChannelStateProvider|useCh # §10 component overrides, context hook arguments, message 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)\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)\b|\bhead=\{' +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 -code '=22.18.0` locally and in CI | [1](#packages) | -| `ChatView`, `useChatViewContext`, `useActiveThread`, `ChatViewSelector*` from `stream-chat-react` | The same names from `stream-chat-react/slot-layout` | [1](#imports-that-moved) | -| `AIStates`, `getAttachmentPreviewUrl`, `isUploadConfirmationPending` from `stream-chat-react` | The same names from `stream-chat` | [1](#imports-that-moved) | -| `hasPendingUploadState` | `isPendingUpload` from `stream-chat` | [1](#imports-that-moved) | -| `StateStore` from `stream-chat` | `StateStore` from `@stream-io/state-store` | [1](#imports-that-moved) | -| `client.queryChannels(...)` returning `Channel[]` | `client.queryChannelsAndHydrate(...)` (`queryChannels` returns the raw response) | [1](#packages) | -| `client.queryUsers(filter, sort, options)` | `client.queryUsers({ payload: { filter_conditions, sort, limit } })` | [1](#packages) | -| `ChannelSort` / `UserSort` / `ReactionSort` objects (`{ last_message_at: -1 }`) | `SortParamRequest[]` (`[{ field: 'last_message_at', direction: -1 }]`) | [1](#packages), [6](#channellist-is-driven-by-a-channelpaginator) | -| `useCreateChatClient({ options })` | `useCreateChatClient({ config: { client: { ... } } })` | [2](#usecreatechatclient-options--config) | -| `enableWSFallback`, `wsUrlParams`, `recoverStateOnReconnect`, `isLocalUnreadCountEnabled` options | `client.wsConnection.enableWSFallback`, `client.wsConnection.urlParams`, `client.connectionRecovery.enabled`, `channel.readEvents.localUnreadCountEnabled` | [2](#usecreatechatclient-options--config) | -| `useChat({ client, defaultLanguage, i18nInstance }).translators` | `useStreami18n({ client, i18nInstance })` | [2](#usechat-and-chatdefaultlanguage) | -| `` | Set the language on the `Streami18n` you pass as `i18nInstance` | [2](#usechat-and-chatdefaultlanguage) | -| `useChatContext().channel` | `useChannel()` in the channel subtree; `useWorkspaceNavigation().openChannels` elsewhere | [2](#chatcontext-fields) | -| `useChatContext().setActiveChannel(channel)` | `useWorkspaceNavigation().openChannel(channel, { event })` | [4](#4-selecting-channels-and-opening-threads-workspace-navigation) | -| `useChatContext().channelsQueryState` | `useChannelPaginatorState(paginator)` | [2](#chatcontext-fields) | -| `useChatContext().latestMessageDatesByChannels` | `useCooldownRemaining()` / `channel.cooldownTimer` | [2](#chatcontext-fields) | -| `useChatContext('MyComponent')` (any context hook with an argument) | No argument | [10](#context-hooks-take-no-componentname-argument) | -| `client.on('connection.changed', ...)` | `useWSConnectionState()` / `useNetworkConnectionState()` | [2](#the-lost-connection-notification) | -| `` | Nothing: render the header, list and composer directly in `` | [3](#window-and-windowprops-are-removed) | -| `` / `` | `` | [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | -| `` | `` or `` | [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | -| `useThreadsViewContext().activeThread` / `.setActiveThread(thread)` | `useSlotThread({ slot })` / `useWorkspaceNavigation().openThread(thread)` | [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | -| `useChatViewContext().activeChatView` / `.setActiveChatView` | `.activeView` / `.setActiveView` | [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | -| `useChannelActionContext().openThread(message)` / `.closeThread` | `useWorkspaceNavigation().openThread({ channel, message })` / `.closeThread(threadId)`, or `useCloseThread()` | [4](#4-selecting-channels-and-opening-threads-workspace-navigation) | -| `` without `channel`, `EmptyPlaceholder` | ``; render `ChannelPlaceholder` (or `ChannelSlot`'s `fallback`) while none is selected | [5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state) | -| `channelQueryOptions`, `initializeOnMount` | `await channel.watch(options)` before rendering | [5](#channel-no-longer-watches-the-channel) | -| `getChannel({ client, id, type })` | `await client.channel(type, id).watch()` | [5](#channel-no-longer-watches-the-channel) | -| `client.channel(type, { members: [id1, id2] })` | `{ members: [{ user_id: id1 }, { user_id: id2 }] }`; render the instance `watch()` resolves with | [5](#channel-no-longer-watches-the-channel) | -| `markReadOnMount` | A message list marks read; without one, `channel.markReadViaReporter()` | [5](#markreadonmount-is-removed-the-list-marks-read) | -| `activeUnreadHandler`, the `(3) Title` tab title | App code | [5](#channel-no-longer-writes-documenttitle) | -| `Channel` `giphyVersion`, `imageAttachmentSizeHandler`, `videoAttachmentSizeHandler`, `shouldGenerateVideoThumbnail` | The same props on `Attachment`, through a `WithComponents` `Attachment` override | [5](#props-that-moved-off-channel) | -| `Channel` `onMentionsClick` / `onMentionsHover` | The same props on `MessageList` / `Message` | [5](#props-that-moved-off-channel) | -| `doSendMessageRequest`, `doUpdateMessageRequest`, `doDeleteMessageRequest`, `doMarkReadRequest` | `client.config.set({ channel: { requestHandlers: { sendMessageRequest, updateMessageRequest, deleteMessageRequest, markReadRequest } } })` | [14](#14-request-handlers) | -| `` | `new ChannelPaginator({ client, filters, sort, id })`, `client.channelManager.insertPaginator({ paginator })`, then `` | [6](#channellist-is-driven-by-a-channelpaginator) | -| `ChannelList` `options.limit` / other `options` | `paginatorOptions.pageSize` / `requestOptions` | [6](#channellist-is-driven-by-a-channelpaginator) | -| `setActiveChannelOnMount` (default `true`) | Nothing opens by default; open the first channel yourself | [6](#v14-list-defaults-that-changed) | -| `showChannelSearch` | `` (header, search and every registered list) | [6](#channellist-is-driven-by-a-channelpaginator) | -| `ChannelListUI` override | The list slots (`EmptyListIndicator`, `FirstPageLoadingIndicator`, `ListItem`, ...) | [6](#channellistui-is-removed-use-the-list-slots) | -| `useChannelListContext().channels` / `.loadNextPage()` / `.setChannels` | `useChannelPaginatorState(paginator).channels` / `paginator.toTail()` / `client.channelManager.ingestChannel(channel)` | [6](#channellistcontext-lost-channels-hasnextpage-loadnextpage-and-setchannels) | -| `ChannelListItemUIProps.lastMessage` / `latestMessagePreview` | `previewedMessage` / `` | [6](#channellistitem-props) | -| `` | `` with `ThreadHeader`, `MessageList` and `MessageComposer` as children | [7](#thread-takes-a-thread-prop-and-renders-its-children) | -| `Thread` `autoFocus` (default `true`) | `` inside the thread | [7](#thread-takes-a-thread-prop-and-renders-its-children) | -| `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) | -| `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) | -| `sendMessage` / `editMessage` / `retrySendMessage` / `deleteMessage` from the action context | `messageComposer.send()` / `.update()`; `channel.messageOperations.retry(...)` / `.delete(...)` | [9](#channelstatecontext-and-channelactioncontext-are-removed) | -| `useTypingContext().typing` | `useStateStore(channel.state, typingSelector).typing` | [9](#typingcontext-is-removed) | -| `channelUnreadUiState` / `setChannelUnreadUiState` | `messagePaginator.unreadStateSnapshot` / `.setUnreadSnapshot(partial)` | [9](#the-unread-snapshot-channelunreaduistate-is-removed) | -| `channelConfig?.typing_events` (and the other flags) | `channel.configState` → `typingEvents.enabled`, `readEvents.enabled`, `replies.enabled`, ... | [9](#channel-configuration-channelconfig--channelconfigstate) | -| `channel.getConfig()` | `channel.serverConfig` (a getter) | [9](#stream-chat-v10-store-changes-you-will-meet-here) | -| `channel.state.readStore` / `typingStore` / `membersStore` / `watcherStore` / `ownCapabilitiesStore` | `channel.state` (one store; drop the `.Store` segment) | [9](#stream-chat-v10-store-changes-you-will-meet-here) | -| `WatcherState` / `channel.disconnected` | `ChannelWatchState` / `channel.pendingDisposal` | [9](#stream-chat-v10-store-changes-you-will-meet-here) | -| `ComponentContext.Message`, the `Message={...}` prop | `MessageUI` override (`VirtualMessage` in `VirtualizedMessageList`) | [10](#message-ui-overrides-consolidate-on-the-messageui-slot) | -| `ComponentContext.ThreadHeader` override | Render `` as a child of `` (not the `ThreadHead` slot) | [10](#removed-componentcontext-slots) | -| `messageActions` prop, `MESSAGE_ACTIONS`, `getMessageActions()` | `messageActionSet` on `MessageActions`; permissions from `useUserRole(message)` | [10](#messageactions-and-message_actions-are-removed) | -| `` | Removed; `reviewProcessedMessage`, or act on the paginator | [11](#the-messages-prop-is-removed) | -| `disableDateSeparator` | `withDateSeparator` (inverted) | [11](#disabledateseparator--withdateseparator) | -| `threadList` / `head` list props | `!!useThreadContext()` / the `ThreadHead` slot | [11](#threadlist-and-head-are-removed-lists-detect-the-thread) | -| `highlightedMessageId` | `messagePaginator.messageFocusSignal` | [11](#pagination-and-state-props-are-removed) | -| `messageLimit` | `client.config.set({ channel: { messagePaginator: { pageSize } } })` | [11](#pagination-and-state-props-are-removed) | -| `useMessageContext().mutes` | `useChatContext().mutes` | [12](#messagecontextmutes-and-messageisunread-are-removed) | -| `retrySendMessage` prop / `retry(message)` | `retrySendMessageRequest` handler / `retry({ localMessage: message })` | [12](#retrysendmessage-and-useretryhandler) | -| `usePinHandler(message).canPin` | `useUserRole(message).canPin` | [12](#usepinhandler-no-longer-returns-canpin) | -| `allowConcurrentPlayback` / `allowConcurrentAudioPlayback` | Removed; one voice message plays at a time | [12](#allowconcurrentplayback-is-removed-one-voice-message-plays-at-a-time) | -| `overrideSubmitHandler` | A `sendMessageRequest` handler on `client.config` | [13](#overridesubmithandler-is-removed) | -| `useMessageComposerContext().handleSubmit` | `useMessageComposerSubmitFn()` | [13](#handlesubmit-is-removed) | -| `preventClearingOnUnmount` | Removed; call `composer.clear()` yourself | [13](#preventclearingonunmount-is-removed) | -| `useAttachmentManagerState().hasCustomDoUploadRequest` | `.customCdn` | [13](#useattachmentmanagerstate-hascustomdouploadrequest--customcdn) | -| `client.setMessageComposerSetupFunction(fn)` | `client.config.setSetupFunction('messageComposer', fn)` | [13](#setmessagecomposersetupfunction-is-deprecated) | -| `new Date(message.created_at)`, `dayjs(created_at)` | `convertTimestampToDate(created_at)` from `stream-chat` (may return `undefined`) | [16](#16-dates-on-response-types-are-unix-nanosecond-numbers) | -| `channel.data?.name` / `.image` | `channel.data?.custom?.name` / `.image` | [17](#17-other-stream-chat-v10-data-shapes) | -| `handleDelete({ deleteForMe, hardDelete })` | `handleDelete({ delete_for_me, hard })` | [17](#17-other-stream-chat-v10-data-shapes) | -| `DefaultCommandData` import, `declare module 'stream-chat' { interface CustomCommandData … }` | Delete both; `Command['name']` is `string` | [17](#17-other-stream-chat-v10-data-shapes) | -| `client.flagMessage(id)` / `channel.addMembers(ids)` / `client.getThread(id, options)` | `client.moderation.flagMessage({ message_id })` / `channel.addMembers({ members: ids })` / `client.getThreadAndHydrate({ message_id }, { watch: true })` | [17](#17-other-stream-chat-v10-data-shapes) | -| `t('English text')` | `t('namespaced.key', 'English text')`, or `t(asDynamicKey('myApp.key'), 'English text')` for app strings | [18](#18-i18n) | -| `deTranslations` ... `trTranslations`, `isLanguageSupported` | Register your own dictionaries; `i18n.registeredLanguages.has(lang)` | [18](#18-i18n) | -| `.str-chat__main-panel`, `.str-chat__container` | `.str-chat__channel` (the content column); size panels on the slot or your own row | [19](#19-css-and-markup) | -| `.str-chat__channel-list-inner` | `.str-chat__channel-list .str-chat__infinite-scroll-paginator` | [19](#19-css-and-markup) | -| Row highlight on `[aria-pressed=true]` | `[aria-selected=true]` | [19](#19-css-and-markup) | +| v14 | v15 | Section | +| -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `stream-chat` v9 | `stream-chat` v10, in the range `peerDependencies['stream-chat']` of the installed `stream-chat-react` declares | [1](#packages) | +| Node < 22.18 | Node `>=22.18.0` locally and in CI | [1](#packages) | +| `ChatView`, `useChatViewContext`, `useActiveThread`, `ChatViewSelector*` from `stream-chat-react` | The same names from `stream-chat-react/slot-layout` | [1](#imports-that-moved) | +| `AIStates`, `getAttachmentPreviewUrl`, `isUploadConfirmationPending` from `stream-chat-react` | The same names from `stream-chat` | [1](#imports-that-moved) | +| `hasPendingUploadState` | `isPendingUpload` from `stream-chat` | [1](#imports-that-moved) | +| `StateStore` from `stream-chat` | `StateStore` from `@stream-io/state-store` | [1](#imports-that-moved) | +| `client.queryChannels(...)` returning `Channel[]` | `client.queryChannelsAndHydrate(...)` (`queryChannels` returns the raw response) | [1](#packages) | +| `client.queryUsers(filter, sort, options)` | `client.queryUsers({ payload: { filter_conditions, sort, limit } })` | [1](#packages) | +| `ChannelSort` / `UserSort` / `ReactionSort` objects (`{ last_message_at: -1 }`) | `SortParamRequest[]` (`[{ field: 'last_message_at', direction: -1 }]`) | [1](#packages), [6](#channellist-is-driven-by-a-channelpaginator) | +| `useCreateChatClient({ options })` | `useCreateChatClient({ config: { client: { ... } } })` | [2](#usecreatechatclient-options--config) | +| `enableWSFallback`, `wsUrlParams`, `recoverStateOnReconnect`, `isLocalUnreadCountEnabled` options | `client.wsConnection.enableWSFallback`, `client.wsConnection.urlParams`, `client.connectionRecovery.enabled`, `channel.readEvents.localUnreadCountEnabled` | [2](#usecreatechatclient-options--config) | +| `useChat({ client, defaultLanguage, i18nInstance }).translators` | `useStreami18n({ client, i18nInstance })` | [2](#usechat-and-chatdefaultlanguage) | +| `` | Set the language on the `Streami18n` you pass as `i18nInstance` | [2](#usechat-and-chatdefaultlanguage) | +| `useChatContext().channel` | `useChannel()` in the channel subtree; `useWorkspaceNavigation().openChannels` elsewhere | [2](#chatcontext-fields) | +| `useChatContext().setActiveChannel(channel)` | `useWorkspaceNavigation().openChannel(channel, { event })` | [4](#4-selecting-channels-and-opening-threads-workspace-navigation) | +| `useChatContext().channelsQueryState` | `useChannelPaginatorState(paginator)` | [2](#chatcontext-fields) | +| `useChatContext().latestMessageDatesByChannels` | `useCooldownRemaining()` / `channel.cooldownTimer` | [2](#chatcontext-fields) | +| `useChatContext('MyComponent')` (any context hook with an argument) | No argument | [10](#context-hooks-take-no-componentname-argument) | +| `client.on('connection.changed', ...)` | `useWSConnectionState()` / `useNetworkConnectionState()` | [2](#the-lost-connection-notification) | +| `` | Nothing: render the header, list and composer directly in `` | [3](#window-and-windowprops-are-removed) | +| `` / `` | `` | [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | +| `` | `` or `` | [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | +| `useThreadsViewContext().activeThread` / `.setActiveThread(thread)` | `useSlotThread({ slot })` / `useWorkspaceNavigation().openThread(thread)` | [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | +| `useChatViewContext().activeChatView` / `.setActiveChatView` | `.activeView` / `.setActiveView` | [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | +| `useChannelActionContext().openThread(message)` / `.closeThread` | `useWorkspaceNavigation().openThread({ channel, message })` / `.closeThread(threadId)`, or `useCloseThread()` | [4](#4-selecting-channels-and-opening-threads-workspace-navigation) | +| `` without `channel`, `EmptyPlaceholder` | ``; render `ChannelPlaceholder` (or `ChannelSlot`'s `fallback`) while none is selected | [5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state) | +| `channelQueryOptions`, `initializeOnMount` | `await channel.watch(options)` before rendering | [5](#channel-no-longer-watches-the-channel) | +| `getChannel({ client, id, type })` | `await client.channel(type, id).watch()` | [5](#channel-no-longer-watches-the-channel) | +| `client.channel(type, { members: [id1, id2] })` | `{ members: [{ user_id: id1 }, { user_id: id2 }] }`; render the instance `watch()` resolves with | [5](#channel-no-longer-watches-the-channel) | +| `markReadOnMount` | A message list marks read; without one, `channel.markReadViaReporter()` | [5](#markreadonmount-is-removed-the-list-marks-read) | +| `activeUnreadHandler`, the `(3) Title` tab title | App code | [5](#channel-no-longer-writes-documenttitle) | +| `Channel` `giphyVersion`, `imageAttachmentSizeHandler`, `videoAttachmentSizeHandler`, `shouldGenerateVideoThumbnail` | The same props on `Attachment`, through a `WithComponents` `Attachment` override | [5](#props-that-moved-off-channel) | +| `Channel` `onMentionsClick` / `onMentionsHover` | The same props on `MessageList` / `Message` | [5](#props-that-moved-off-channel) | +| `doSendMessageRequest`, `doUpdateMessageRequest`, `doDeleteMessageRequest`, `doMarkReadRequest` | `client.config.set({ channel: { requestHandlers: { sendMessageRequest, updateMessageRequest, deleteMessageRequest, markReadRequest } } })` | [14](#14-request-handlers) | +| `` | `new ChannelPaginator({ client, filters, sort, id })`, `client.channelManager.insertPaginator({ paginator })`, then `` | [6](#channellist-is-driven-by-a-channelpaginator) | +| `ChannelList` `options.limit` / other `options` | `paginatorOptions.pageSize` / `requestOptions` | [6](#channellist-is-driven-by-a-channelpaginator) | +| `setActiveChannelOnMount` (default `true`) | Nothing opens by default; open the first channel yourself | [6](#v14-list-defaults-that-changed) | +| `showChannelSearch` | `` (header, search and every registered list) | [6](#channellist-is-driven-by-a-channelpaginator) | +| `ChannelListUI` override | The list slots (`EmptyListIndicator`, `FirstPageLoadingIndicator`, `ListItem`, ...) | [6](#channellistui-is-removed-use-the-list-slots) | +| `useChannelListContext().channels` / `.loadNextPage()` / `.setChannels` | `useChannelPaginatorState(paginator).channels` / `paginator.toTail()` / `client.channelManager.ingestChannel(channel)` | [6](#channellistcontext-lost-channels-hasnextpage-loadnextpage-and-setchannels) | +| `ChannelListItemUIProps.lastMessage` / `latestMessagePreview` | `previewedMessage` / `` | [6](#channellistitem-props) | +| `` | `` with `ThreadHeader`, `MessageList` and `MessageComposer` as children | [7](#thread-takes-a-thread-prop-and-renders-its-children) | +| `Thread` `autoFocus` (default `true`) | `` inside the thread | [7](#thread-takes-a-thread-prop-and-renders-its-children) | +| `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) | +| `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) | +| `sendMessage` / `editMessage` / `retrySendMessage` / `deleteMessage` from the action context | `messageComposer.send()` / `.update()`; `channel.messageOperations.retry(...)` / `.delete(...)` | [9](#channelstatecontext-and-channelactioncontext-are-removed) | +| `useTypingContext().typing` | `useStateStore(channel.state, typingSelector).typing` | [9](#typingcontext-is-removed) | +| `channelUnreadUiState` / `setChannelUnreadUiState` | `messagePaginator.unreadStateSnapshot` / `.setUnreadSnapshot(partial)` | [9](#the-unread-snapshot-channelunreaduistate-is-removed) | +| `channelConfig?.typing_events` (and the other flags) | `channel.configState` → `typingEvents.enabled`, `readEvents.enabled`, `replies.enabled`, ... | [9](#channel-configuration-channelconfig--channelconfigstate) | +| `channel.getConfig()` | `channel.serverConfig` (a getter) | [9](#stream-chat-v10-store-changes-you-will-meet-here) | +| `channel.state.readStore` / `typingStore` / `membersStore` / `watcherStore` / `ownCapabilitiesStore` | `channel.state` (one store; drop the `.Store` segment) | [9](#stream-chat-v10-store-changes-you-will-meet-here) | +| `WatcherState` / `channel.disconnected` | `ChannelWatchState` / `channel.pendingDisposal` | [9](#stream-chat-v10-store-changes-you-will-meet-here) | +| `ComponentContext.Message`, the `Message={...}` prop | `MessageUI` override (`VirtualMessage` in `VirtualizedMessageList`) | [10](#message-ui-overrides-consolidate-on-the-messageui-slot) | +| `ComponentContext.ThreadHeader` override | Render `` as a child of `` (not the `ThreadHead` slot) | [10](#removed-componentcontext-slots) | +| `messageActions` prop, `MESSAGE_ACTIONS`, `getMessageActions()` | `messageActionSet` on `MessageActions`; permissions from `useMessagePermissions(message)` | [10](#messageactions-and-message_actions-are-removed) | +| `` | Removed; `reviewProcessedMessage`, or act on the paginator | [11](#the-messages-prop-is-removed) | +| `disableDateSeparator` | `withDateSeparator` (inverted) | [11](#disabledateseparator--withdateseparator) | +| `threadList` / `head` list props | `!!useThreadContext()` / the `ThreadHead` slot | [11](#threadlist-and-head-are-removed-lists-detect-the-thread) | +| `highlightedMessageId` | `messagePaginator.messageFocusSignal` | [11](#pagination-and-state-props-are-removed) | +| `messageLimit` | `client.config.set({ channel: { messagePaginator: { pageSize } } })` | [11](#pagination-and-state-props-are-removed) | +| `noGroupByUser` / `shouldGroupByUser` / `groupStyles` | `groupByUser` / `getGroupStyles` (one object argument) | [11](#message-grouping-groupbyuser-and-getgroupstyles) | +| `VirtualizedMessageList` `defaultItemHeight` / `overscan` / `scrollSeekPlaceHolder` | `additionalVirtuosoProps` | [11](#virtualizedmessagelist-defaultitemheight-overscan-and-scrollseekplaceholder-are-removed) | +| `useMessageContext().mutes` | `useChatContext().mutes` | [12](#messagecontextmutes-and-messageisunread-are-removed) | +| `retrySendMessage` prop / `retry(message)` | `retrySendMessageRequest` handler / `retry({ localMessage: message })` | [12](#retrysendmessage-and-useretryhandler) | +| `usePinHandler(message).canPin` | `useMessagePermissions(message).canPin` | [12](#usepinhandler-no-longer-returns-canpin) | +| `useUserRole(message)` | `useMessagePermissions(message)` | [12](#useuserrole-is-renamed-to-usemessagepermissions) | +| `useUserRole(message).isAdmin` / `.isModerator` / `.isOwner` | The `can*` flags, or `useChannelCapabilities()` | [12](#useuserrole-no-longer-returns-isadmin-ismoderator-or-isowner) | +| `disableQuotedMessages` | A `messageActionSet` without the `quote` item | [12](#the-disablequotedmessages-prop-is-removed) | +| `renderText(text, mentionedUsers, options)` | `renderText(text, { ...options, messageMentionEntities: getRenderTextMentionEntities(message) })` | [12](#rendertext-takes-text-options) | +| `node.mentionedUser` in a custom mention renderer | `node.mentionedEntity` | [12](#custom-mention-renderers-read-nodementionedentity) | +| `onMentionsClick(event, mentioned_users)` | `onMentionsClick(event, message)` | [12](#mention-handlers-receive-event-message) | +| `allowConcurrentPlayback` / `allowConcurrentAudioPlayback` | Removed; one voice message plays at a time | [12](#allowconcurrentplayback-is-removed-one-voice-message-plays-at-a-time) | +| `overrideSubmitHandler` | A `sendMessageRequest` handler on `client.config` | [13](#overridesubmithandler-is-removed) | +| `useMessageComposerContext().handleSubmit` | `useMessageComposerSubmitFn()` | [13](#handlesubmit-is-removed) | +| `preventClearingOnUnmount` | Removed; call `composer.clear()` yourself | [13](#preventclearingonunmount-is-removed) | +| `useAttachmentManagerState().hasCustomDoUploadRequest` | `.customCdn` | [13](#useattachmentmanagerstate-hascustomdouploadrequest--customcdn) | +| `client.setMessageComposerSetupFunction(fn)` | `client.config.setSetupFunction('messageComposer', fn)` | [13](#setmessagecomposersetupfunction-is-deprecated) | +| `new Date(message.created_at)`, `dayjs(created_at)` | `convertTimestampToDate(created_at)` from `stream-chat` (may return `undefined`) | [16](#16-dates-on-response-types-are-unix-nanosecond-numbers) | +| `channel.data?.name` / `.image` | `channel.data?.custom?.name` / `.image` | [17](#17-other-stream-chat-v10-data-shapes) | +| `handleDelete({ deleteForMe, hardDelete })` | `handleDelete({ delete_for_me, hard })` | [17](#17-other-stream-chat-v10-data-shapes) | +| `DefaultCommandData` import, `declare module 'stream-chat' { interface CustomCommandData … }` | Delete both; `Command['name']` is `string` | [17](#17-other-stream-chat-v10-data-shapes) | +| `client.flagMessage(id)` / `channel.addMembers(ids)` / `client.getThread(id, options)` | `client.moderation.flagMessage({ message_id })` / `channel.addMembers({ members: ids })` / `client.getThreadAndHydrate({ message_id }, { watch: true })` | [17](#17-other-stream-chat-v10-data-shapes) | +| `t('English text')` | `t('namespaced.key', 'English text')`, or `t(asDynamicKey('myApp.key'), 'English text')` for app strings | [18](#18-i18n) | +| `deTranslations` ... `trTranslations`, `isLanguageSupported` | Register your own dictionaries; `i18n.registeredLanguages.has(lang)` | [18](#18-i18n) | +| `.str-chat__main-panel`, `.str-chat__container` | `.str-chat__channel` (the content column); size panels on the slot or your own row | [19](#19-css-and-markup) | +| `.str-chat__channel-list-inner` | `.str-chat__channel-list .str-chat__infinite-scroll-paginator` | [19](#19-css-and-markup) | +| Row highlight on `[aria-pressed=true]` | `[aria-selected=true]` | [19](#19-css-and-markup) | ## Breaks that do not fail to compile 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)) | `` from its `customMessageRenderer`, and from a `ThreadHead` override for the thread's parent message), and call the handlers with `?.` ([section 12](#onmentionsclickmessage-and-onmentionshovermessage-are-undefined-without-a-handler)) | `onMentionsClickMessage`, `onMentionsHoverMessage` | +| A supplied composer keeps its content after `MessageComposer` unmounts | A supplied composer is never cleared on unmount | Call `composer.clear()` yourself ([section 13](#preventclearingonunmount-is-removed)) | `MessageComposerControllerProvider` | +| A voice message stops another one playing in a different panel | Playback is exclusive app-wide | Nothing to do ([section 12](#allowconcurrentplayback-is-removed-one-voice-message-plays-at-a-time)) | `allowConcurrent` | +| A custom composer without `CommandChip` loses `/giphy` from the text as soon as the command is typed | `stream-chat` v10 installs the command middlewares, which move the active command out of the text | Remove the four command middlewares ([section 13](#command-middlewares-are-installed-by-stream-chat)) | `stream-io/text-composer/(active-command-guard | command-string-extraction)`, `command-string-injection`, a custom composer that renders no `CommandChip` | +| The attachment menu offers different actions than before | Actions follow the composer's resolved configuration | Set `customCdn` where it applies ([section 13](#attachment-and-poll-availability)) | `doUploadRequest`, `customCdn` | +| A custom `MessageTimestamp` does not show in the header of a gallery you build yourself | The gallery renders outside a message and reads the item; it uses the override only when the item carries its `message` | Set `message` on each `GalleryItem` ([section 12](#the-gallery-header-renders-the-messagetimestamp-override-for-items-that-carry-their-message)) | `GalleryItem`, `` 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 ``) | -| `autoFocus` (default `true`) | `` in every thread that should focus, whether or not you passed `autoFocus` | -| `enableDateSeparator` | `withDateSeparator` on the list | -| `Message` | A `MessageUI` override scoped with `WithComponents` | -| `messageActions` | Removed ([section 10](#messageactions-and-message_actions-are-removed)) | -| `virtualized` | Render the list you want | +| Removed `ThreadProps` | v15 | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `additionalMessageComposerProps` | Props on your `MessageComposer` child | +| `additionalMessageListProps` | Props on your `MessageList` child | +| `additionalVirtualizedMessageListProps` | Props on your `VirtualizedMessageList` child | +| `additionalParentMessageProps` | The list's own message props, which reach the parent message too; for anything else, a `ThreadHead` override on `ComponentContext` (typed `ComponentType`). The default `ThreadHead` is not exported, so an override renders the whole head (`DateSeparator` for the parent's `created_at`, `` and `ThreadStart`) | +| `autoFocus` (default `true`) | `` in every thread that should focus, whether or not you passed `autoFocus` | +| `enableDateSeparator` | `withDateSeparator` on the list | +| `Message` | A `MessageUI` override scoped with `WithComponents` | +| `messageActions` | Removed ([section 10](#messageactions-and-message_actions-are-removed)) | +| `virtualized` | Render the list you want | -**Defaults changed.** A `MessageList` inside a thread now shows date separators unless you pass `withDateSeparator={false}` (v14's `Thread` turned them off). `autoFocus` defaulted to `true`, so every v14 thread focused its composer on open, not only threads that passed the prop; in v15 the composer is focused only if you render ``. The parent message is rendered by the message list, inside the scroll container; both lists resolve it from the thread context. +**Defaults changed.** A `MessageList` inside a thread now shows date separators unless you pass `withDateSeparator={false}` (v14's `Thread` turned them off). `autoFocus` defaulted to `true`, so every v14 thread focused its composer on open, not only threads that passed the prop; in v15 the composer is focused only if you render ``. + +**The parent message** is rendered by the message list, inside the scroll container; both lists resolve it from the thread context. It gets the list-wide message props the replies get, so `onMentionsClick` or `onUserClick` on `MessageList` works on the parent message too. `MessageList` passes `onMentionsClick`, `onMentionsHover`, `onUserClick`, `onUserHover`, `renderText`, `formatDate`, `unsafeHTML`, `showAvatar`, `reactionDetailsSort`, `sortReactions` and `closeReactionSelectorOnClick`; `VirtualizedMessageList` passes the ones it has (`renderText`, `formatDate`, `showAvatar`, `reactionDetailsSort`, `sortReactions`, `closeReactionSelectorOnClick`). Both lists also pass `additionalMessageComposerProps`, which no message component reads. The read state does not reach the parent message: neither list passes `returnAllReadData`, `readBy` or `deliveredTo` to it, and `MessageStatus` shows no read state inside a thread. A `ThreadHead` override that needs them sets them itself. Only the props you set are passed, so a `ThreadHead` override receives them too, without overwriting its own defaults with `undefined`. ```tsx // v14 @@ -1984,7 +1997,7 @@ Related type changes: The `messageActions` prop on `Message`, `MessageList`, `VirtualizedMessageList` and `Thread` is removed, along with `MESSAGE_ACTIONS`, `OPTIONAL_MESSAGE_ACTIONS`, `MessageActionsArray`, `Capabilities`, the `getMessageActions()` util, and the `getMessageActions` / `actionsEnabled` fields of `MessageContextValue` (also on `MessageUIComponentProps`, `SharedMessageProps` and `VirtuosoContext`). -**If your custom message UI calls `getMessageActions()` or checks `MESSAGE_ACTIONS`** (for example to show a reply or react button only when allowed), it has to change: read the permissions from `useUserRole(message)` instead (example at the end of this section). The call fails to compile, and in untyped code throws `TypeError: getMessageActions is not a function`. +**If your custom message UI calls `getMessageActions()` or checks `MESSAGE_ACTIONS`** (for example to show a reply or react button only when allowed), it has to change: read the permissions from `useMessagePermissions(message)` instead (example at the end of this section). The call fails to compile, and in untyped code throws `TypeError: getMessageActions is not a function`. Otherwise there is little to do. The prop had already stopped working in v14: `getMessageActions` was written into `MessageContext`, but none of the SDK's own components read it, so passing `messageActions={['delete']}` in v14 changed nothing on screen, and the default rendering does not change now. @@ -2037,16 +2050,16 @@ export const List = () => ( ``` - A hand-written `edit`, `copyMessageText` or `download` item without `isVisible` now shows wherever the base filter allows it, e.g. `edit` on a poll message the user may update. Copy the default item's `isVisible` to keep the default rule. -- `useUserRole(message).canEdit` is now the permission alone (`update-any-message`, or `update-own-message` on the user's own message), the same check the base filter makes. It no longer excludes poll messages and giphies; a custom UI that gated an edit button on it and wants that rule adds `!message.poll_id && message.command !== 'giphy'` itself. +- `useMessagePermissions(message).canEdit` (`useUserRole` in v14) is now the permission alone (`update-any-message`, or `update-own-message` on the user's own message), the same check the base filter makes. It no longer excludes poll messages and giphies; a custom UI that gated an edit button on it and wants that rule adds `!message.poll_id && message.command !== 'giphy'` itself. - There is no `actionsEnabled` replacement. The base filter already drops actions on deleted, bounced and failed messages. -- **A custom `MessageUI` that gated its own buttons** on `getMessageActions()` / `MESSAGE_ACTIONS` (for example showing a react or reply button only when allowed) reads the permission from `useUserRole(message)`, which returns `canReact`, `canReply`, `canPin`, `canEdit`, `canDelete`, `canFlag`, `canMute`, `canQuote`, `canMarkUnread`, `canBlockUser`, `canSendMessage`, `isMyMessage` and the role flags: +- **A custom `MessageUI` that gated its own buttons** on `getMessageActions()` / `MESSAGE_ACTIONS` (for example showing a react or reply button only when allowed) reads the permission from `useMessagePermissions(message)`, which returns `canReact`, `canReply`, `canPin`, `canEdit`, `canDelete`, `canFlag`, `canMute`, `canQuote`, `canMarkUnread`, `canBlockUser`, `canSendMessage` and `isMyMessage`: ```tsx - import { useMessageContext, useUserRole } from 'stream-chat-react'; + import { useMessageContext, useMessagePermissions } from 'stream-chat-react'; export const ReplyButton = () => { const { handleOpenThread, message } = useMessageContext(); - const { canReply } = useUserRole(message); + const { canReply } = useMessagePermissions(message); return canReply ? : null; }; ``` @@ -2171,6 +2184,29 @@ In untyped code the old props are ignored and the defaults apply: `MessageList n - Renamed: `shouldGroupByUser` → `groupByUser`. - `head`, `firstUnreadMessageId` and `lastReadMessageId` are `... | null` (checks written as `=== undefined` miss them); `lastReadDate` is `TimestampNS | null` (unix nanoseconds); `loadingMore: boolean`; `unreadMessageCount: number`. +### `VirtualizedMessageList`: `defaultItemHeight`, `overscan` and `scrollSeekPlaceHolder` are removed + +Pass them to `react-virtuoso` through `additionalVirtuosoProps`. `react-virtuoso` defaults `overscan` to `0`, as the v14 prop did, so leaving it out keeps the v14 behavior. + +| v14 prop | v15 | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `defaultItemHeight` | `additionalVirtuosoProps.defaultItemHeight` | +| `overscan` | `additionalVirtuosoProps.overscan` | +| `scrollSeekPlaceHolder` | `additionalVirtuosoProps.scrollSeekConfiguration` and `additionalVirtuosoProps.components.ScrollSeekPlaceholder` | + +In v14, `scrollSeekPlaceHolder` reached `react-virtuoso` as its deprecated `scrollSeek` prop, which logged a warning. `scrollSeekConfiguration` with `components.ScrollSeekPlaceholder` keeps the same behavior without the warning. `defaultItemHeight` and `scrollSeekPlaceHolder` used to win over `additionalVirtuosoProps`; now `additionalVirtuosoProps` is the only source. + +```tsx +// v14 + +``` + +```tsx + +``` + ### `headerPosition` and `insertIntro` take unix nanoseconds `MessageList.headerPosition` and `insertIntro(messages, headerPosition)` are compared against `message.created_at`, so they are `TimestampNS`, not epoch milliseconds. A millisecond `number` no longer compiles; with a cast, the header lands in the wrong place. Pass `message.created_at` or `msToNs(ms)` from `stream-chat`. @@ -2273,7 +2309,7 @@ export const MessagePreview = ({ ### `MessageContext` grouping flags are removed; read `groupStyles` -`endOfGroup`, `firstOfGroup` and `groupedByUser` are removed from `MessageContextValue`. The message lists never set them, so in v14 they were always `undefined`. A message's place in its group of consecutive messages from one user is `groupStyles[0]` from `useMessageContext()`: `top`, `middle`, `bottom` or `single`. The default `MessageUI` renders the metadata on every message, and the default CSS hides it on `top` and `middle` list items (`.str-chat__li--top`, `.str-chat__li--middle`), so an app can show it again with CSS alone. The `str-chat__virtual-message__wrapper--end`, `--first` and `--group` classes these flags added are removed too ([section 19](#19-css-and-markup)). +`endOfGroup`, `firstOfGroup` and `groupedByUser` are removed from `MessageContextValue`. The message lists never set them, so in v14 they were always `undefined`. A message's place in its group of consecutive messages from one user is `groupStyles?.[0]` from `useMessageContext()`: `top`, `middle`, `bottom` or `single`, and `undefined` for a message no list groups (a standalone ``, or the thread's parent message). The default `MessageUI` renders the metadata on every message, and the default CSS hides it on `top` and `middle` list items (`.str-chat__li--top`, `.str-chat__li--middle`), so an app can show it again with CSS alone. The `str-chat__virtual-message__wrapper--end`, `--first` and `--group` classes these flags added are removed too ([section 19](#19-css-and-markup)). ### `retrySendMessage` and `useRetryHandler` @@ -2299,7 +2335,179 @@ export const RetryButton = () => { ### `usePinHandler` no longer returns `canPin` -`usePinHandler(message, notifications?)` returns `{ handlePin }`. Read `canPin` from `useUserRole(message).canPin`. In untyped code the removed field is `undefined`, so a pin button gated on it never shows. +`usePinHandler(message, notifications?)` returns `{ handlePin }`. Read `canPin` from `useMessagePermissions(message).canPin`. In untyped code the removed field is `undefined`, so a pin button gated on it never shows. + +### `useUserRole` is renamed to `useMessagePermissions` + +The hook returns what the current user may do with one message, not their role, so it is now `useMessagePermissions`. It takes only the message (the `disableQuotedMessages` argument is removed, see [The `disableQuotedMessages` prop is removed](#the-disablequotedmessages-prop-is-removed)) and returns `canBlockUser`, `canDelete`, `canEdit`, `canFlag`, `canMarkUnread`, `canMute`, `canPin`, `canQuote`, `canReact`, `canReply`, `canSendMessage` and `isMyMessage`. Rename the import and the call. In untyped code the old import is `undefined`, so the call throws `TypeError: useUserRole is not a function`. + +```tsx +// v14 +import { useUserRole } from 'stream-chat-react'; + +const { canReply } = useUserRole(message); +``` + +```tsx +import { useMessagePermissions } from 'stream-chat-react'; + +const { canReply } = useMessagePermissions(message); +``` + +### `useUserRole` no longer returns `isAdmin`, `isModerator` or `isOwner` + +The three flags read the user's role, which is not what decides what they may do in a channel. Gate on a permission instead: `useMessagePermissions(message)` returns `canDelete`, `canEdit`, `canFlag`, `canMarkUnread`, `canMute`, `canPin`, `canQuote`, `canReact`, `canReply`, `canSendMessage`, `canBlockUser` and `isMyMessage`, all built from the channel's capabilities. For any other capability, read `useChannelCapabilities({ cid: channel.cid })`, a `Set` of capability names. Where the role itself is needed, read `client.user?.role` or `channel.state.membership`. In untyped code the removed flags are `undefined`, so UI gated on them never shows. + +```tsx +// v14 +const { isAdmin, isModerator } = useUserRole(message); +const showModerationTools = isAdmin || isModerator; +``` + +```tsx +const channel = useChannel(); +const capabilities = useChannelCapabilities({ cid: channel.cid }); +const showModerationTools = capabilities.has('ban-channel-members'); +``` + +### The `disableQuotedMessages` prop is removed + +`MessageList` and `Message` no longer take `disableQuotedMessages`, and `useMessagePermissions` takes only the message; its `canQuote` is the `quote-message` capability alone. Passing either is a compile error; in untyped code both are ignored. Nothing changes on screen: v14's default message actions already ignored the flag and showed Quote whether or not it was set. To hide Quote, leave it out of the action set ([section 10](#messageactions-and-message_actions-are-removed)): + +```tsx +// v14 + +``` + +```tsx +import { + defaultMessageActionSet, + MessageActions, + MessageList, + WithComponents, +} from 'stream-chat-react'; + +const messageActionSet = defaultMessageActionSet.filter( + // the dropdown-toggle item has no `type`; keep it + (item) => !('type' in item) || item.type !== 'quote', +); + +const MessageActionsWithoutQuote = () => ( + +); + +export const List = () => ( + + + +); +``` + +### `renderText` takes `(text, options)` + +The `mentionedUsers` argument is removed, so `renderText(text, mentionedUsers, options)` becomes `renderText(text, options)`, and so does the `RenderTextFunction` type of the `renderText` prop on `MessageList`, `VirtualizedMessageList`, `Message`, `MessageText`, `QuotedMessage` and `QuotedMessagePreview`. Pass mentions as `options.messageMentionEntities`, built from a message with `getRenderTextMentionEntities(message)`; it covers `@channel`, `@here`, roles and user groups too. A custom `renderText` written for the old order reads the options object as `mentionedUsers` and loses them. + +```tsx +// v14 +const customRenderText: RenderTextFunction = (text, mentionedUsers, options) => + renderText(text, mentionedUsers, { ...options, customMarkDownRenderers }); +``` + +```tsx +const customRenderText: RenderTextFunction = (text, options) => + renderText(text, { ...options, customMarkDownRenderers }); +``` + +### `mentionsMarkdownPlugin` takes mention entities only + +`mentionsMarkdownPlugin` and `createMentionPluginAndDisplayTextSet` no longer accept a users array. Pass `RenderTextMentionEntity[]`, built with `getRenderTextMentionEntities(message)`, which also covers `@channel`, `@here`, roles and user groups. Passing users fails to compile. In untyped code a user without `mentionType` matches by name and by ID, and renders with `data-mention-id` but without `data-mention-type="user"` or `data-user-id`. + +```tsx +// v14 +renderText(message.text, { + getRehypePlugins: (plugins) => [ + mentionsMarkdownPlugin(message.mentioned_users ?? []), + ...plugins, + ], +}); +``` + +```tsx +renderText(message.text, { + getRehypePlugins: (plugins) => [ + mentionsMarkdownPlugin(getRenderTextMentionEntities(message)), + ...plugins, + ], +}); +``` + +### Custom mention renderers read `node.mentionedEntity` + +The `mention` node given to `customMarkDownRenderers.mention` no longer carries `mentionedUser`. Read `node.mentionedEntity`: for a user mention its `mentionType` is `'user'` and it carries the user's fields. + +```tsx +// v14 +const CustomMention = ({ children, node }: MentionProps) => ( + {children} +); +``` + +```tsx +const CustomMention = ({ children, node: { mentionedEntity } }: MentionProps) => ( + + {children} + +); +``` + +### Mention handlers receive `(event, message)` + +`onMentionsClick` and `onMentionsHover` (on `MessageList` or `Message`) are called with `(event, message)`; the v14 `mentioned_users` argument between them is removed, and so is the `MentionedUserEventHandler` type; type such handlers as `CustomMentionHandler`. Read `message.mentioned_users`, `mentioned_channel`, `mentioned_here`, `mentioned_roles` and `mentioned_groups`. The handler runs for an event anywhere in the message text; to find the mention that was clicked, read `data-mention-id`, `data-mention-type` or `data-user-id` from the target element. Enter or Space on the focused message text also calls the handler; its target is the text element, not a mention. + +```tsx +// v14 + openProfile(mentionedUsers[0]?.id)} +/> +``` + +```tsx +const getMentionedUserId = (event: React.BaseSyntheticEvent, message: LocalMessage) => { + // Enter or Space targets the message text, not a mention, so it identifies a user only when + // the message's one mention is a user (no second user, @channel, @here, role or group). + if (event.type === 'keydown') { + const [mention, ...otherMentions] = getRenderTextMentionEntities(message); + if (otherMentions.length || mention?.mentionType !== 'user') return undefined; + return mention.id; + } + return (event.target as HTMLElement).closest('[data-user-id]')?.dataset + .userId; +}; + + { + const userId = getMentionedUserId(event, message); + if (userId) openProfile(userId); + }} +/>; +``` + +### `onMentionsClickMessage` and `onMentionsHoverMessage` are `undefined` without a handler + +`useMessageContext()` returns `onMentionsClickMessage` and `onMentionsHoverMessage` only when `onMentionsClick` / `onMentionsHover` was passed to `MessageList` or `Message`. `useMentionsHandler(message, { onMentionsClick, onMentionsHover })` likewise returns each handler only when you pass the matching one. A custom message UI that calls them directly needs `?.`, or passes them as event props. In untyped code a direct call throws `TypeError: onMentionsClickMessage is not a function`. In a thread, the parent message gets the list's handlers like the replies do. Without `onMentionsClick`, mentions in `MessageText` are plain text for the user: no separate focus stop, no Enter or Space handling, and the default cursor. With it, the text element gets `str-chat__message-text-inner--mentions-clickable` and mentions get a pointer cursor. + +```tsx +// v14 +const { onMentionsClickMessage } = useMessageContext(); +const onKeyDown = (event) => onMentionsClickMessage(event); +``` + +```tsx +const { onMentionsClickMessage } = useMessageContext(); +const onKeyDown = (event) => onMentionsClickMessage?.(event); +``` ### Message action handlers reject when they fail @@ -3186,8 +3394,8 @@ 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|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)\b| string`, `Timestamp.timestamp?: Date | string`. Outgoing request fields (filter bounds, `remind_at`) stay `Date`. - Evidence: - `release-v14:src/components/MessageList/utils.ts:34` `lastRead?: Date | null`, `:205` `insertIntro(…, headerPosition?: number)`, `:360` `lastReadDate?: Date`; `release-v14:src/components/MessageList/MessageList.tsx:529` `headerPosition?: number`; `release-v14:src/components/MessageList/VirtualizedMessageList.tsx:134` `lastReadDate?: Date` - - `master:src/components/MessageList/utils.ts:47` `lastRead?: TimestampNS | null`, `:223` `headerPosition?: TimestampNS`; `master:src/components/MessageList/MessageList.tsx:474` `headerPosition?: TimestampNS`; `master:src/components/MessageList/VirtualizedMessageList.tsx:135` `lastReadDate: TimestampNS | null` + - `master:src/components/MessageList/utils.ts:45` `lastRead?: TimestampNS | null`, `:223` `headerPosition?: TimestampNS`; `master:src/components/MessageList/MessageList.tsx:449` `headerPosition?: TimestampNS`; `master:src/components/MessageList/VirtualizedMessageList.tsx:130` `lastReadDate: TimestampNS | null` - `master:src/components/DateSeparator/DateSeparator.tsx:13,17` `date: Date`, `formatDate?: (date: Date) => string`; `master:src/components/Message/Timestamp.tsx:12` `timestamp?: Date | string` - `stream-chat@10.0.0-rc.22` `dist/types/utils/time.d.ts:39-85` `asTimestampNS`, `nsToMs`, `msToNs`, `nowNs`, `nsToDate`, `dateToNs`, `convertTimestampToDate`; re-exported at `dist/types/index.d.ts:42` - commits: `cb472c35f feat!: move to timestamps in response models` (PR #3278); `aaa7fa31e feat!: adopt stream-chat's branded TimestampNS` (PR #3297) @@ -296,7 +305,7 @@ export const DayDivider = ({ message }: { message: LocalMessage }) => { - `master:src/context/ChannelInstanceContext.tsx:28` `useChannelInstanceContext(): Partial` - `master:src/context/AttachmentContext.tsx:19-24,40` `AttachmentContextValue` { `giphyVersion`, `imageAttachmentSizeHandler`, `shouldGenerateVideoThumbnail`, `videoAttachmentSizeHandler` }, `useAttachmentContext` - `master:src/components/Channel/hooks/useChannelCapabilities.ts:10` `useChannelCapabilities({ cid })` - - `master:src/components/MessageList/MessageList.tsx:243` older page via `messagePaginator.toTail()`; `:388` newer via `messagePaginator.toHead` + - `master:src/components/MessageList/MessageList.tsx:238` older page via `messagePaginator.toTail()`; `:372` newer via `messagePaginator.toHead` - `master:src/components/Message/hooks/useRetryHandler.ts:16` `(thread ?? channel).messageOperations.retry(params)`; `master:src/components/Message/hooks/useDeleteHandler.ts:36` `entity.messageOperations.delete(...)` - `master:src/components/Threads/hooks/useCloseThread.ts:14`; `master:src/context/WorkspaceNavigationContext.tsx:102,136` `closeThread`, `openThread` - `stream-chat@10.0.0-rc.22` `dist/types/pagination/paginators/BasePaginator.d.ts:505-516` `hasMoreTail` / `hasMoreHead` (the `hasNext` / `hasPrev` aliases are `@deprecated`), `:1154-1163` `toTail` / `toHead` (`next` / `prev` are `@deprecated`); `MessageIntervalPaginator.d.ts:279-280`, `MessagePaginator.d.ts:144,148,199,200` @@ -613,7 +622,7 @@ export const CustomEmojiPicker = () => { - `release-v14:src/types/types.ts:54` `export type ChannelUnreadUiState = Omit, 'user'>`; `release-v14:src/types/index.ts:1` - `master:src/types/index.ts:1` and `master:src/types/types.ts:34`: both commented out - `stream-chat@10.0.0-rc.22` `dist/types/pagination/paginators/MessagePaginator.d.ts:49-55` `UnreadSnapshotState`, `:87` `unreadStateSnapshot: StateStore`, `:200` `setUnreadSnapshot` - - `master:src/components/MessageList/MessageList.tsx:136` reads `messagePaginator.unreadStateSnapshot` + - `master:src/components/MessageList/MessageList.tsx:146` reads `messagePaginator.unreadStateSnapshot` - commit: `99cbe6704` (PR #3237) - Docs impact: - `02-ui-components/04-channel/01-channel.md`, `02-ui-components/04-channel/04-channel_state_context.md`, `04-guides/05-channel_read_state.md` @@ -1417,10 +1426,10 @@ Note: a bare `AttachmentContextProvider` above the list does not reach message a - Old API: - `ChannelProps.onMentionsClick?: OnMentionAction`, `ChannelProps.onMentionsHover?: OnMentionAction` - `useChannelMentionsHandler(onMentionsHover?, onMentionsClick?)` -- Replacement: `onMentionsClick` / `onMentionsHover` on `MessageList` (drilled to each message) or on `Message`; handler type `CustomMentionHandler` `(event, mentioned_users: UserResponse[], message: LocalMessage)` (the second argument is `@deprecated`; read mention metadata from `message`). `VirtualizedMessageList` has no such prop in v15 (v14 virtualized lists got the handlers only from `Channel`), so a virtualized list needs a custom message UI to handle mention clicks. +- Replacement: `onMentionsClick` / `onMentionsHover` on `MessageList` (drilled to each message) or on `Message`; handler type `CustomMentionHandler` `(event, message: LocalMessage)`; read mention metadata from `message` (BC-089). `VirtualizedMessageList` has no such prop in v15 (v14 virtualized lists got the handlers only from `Channel`), so a virtualized list needs a custom message UI to handle mention clicks. - Evidence: - `release-v14:src/components/Channel/Channel.tsx:145-147`; `release-v14:src/components/Channel/index.ts:3`; `release-v14:src/components/Message/hooks/useMentionsHandler.ts:51-59` (fell back to the `Channel` handlers) - - `master:src/components/MessageList/MessageList.tsx:215-216,440-441,458` (`onMentionsClick`/`onMentionsHover` in `PropsDrilledToMessage`); `master:src/components/MessageList/VirtualizedMessageList.tsx:85-91` (not drilled); `master:src/components/Message/types.ts:44-47` + - `master:src/components/MessageList/MessageList.tsx:108-109,421-422` (`onMentionsClick`/`onMentionsHover` in `PropsDrilledToMessage`); `master:src/components/MessageList/VirtualizedMessageList.tsx:84-90` (not drilled); `master:src/components/Message/types.ts:54-56` - commit: `99cbe6704` (PR #3237) - Docs impact: - `02-ui-components/04-channel/01-channel.md`, `02-ui-components/04-channel/03-channel_action_context.md`, `03-ui-cookbook/04-message/03-mentions_actions.md` @@ -1858,7 +1867,7 @@ const HeaderStart = () => ( - `` reading the thread from `ChannelStateContext` / `ThreadContext` - `ThreadProps`: `additionalMessageComposerProps`, `additionalMessageListProps`, `additionalParentMessageProps`, `additionalVirtualizedMessageListProps`, `autoFocus`, `enableDateSeparator`, `Message`, `messageActions`, `virtualized` - `useLegacyThreadContext` -- Replacement: `` + children (`ThreadHeader`, `MessageList` or `VirtualizedMessageList`, `MessageComposer focus`). Get the instance from `client.threads.ensure({ channel, parentMessage })` / `client.threads.get(...)`, from a `ThreadSlot`, or from workspace navigation. Per prop: list/composer props go on the children; `additionalParentMessageProps` -> a `ThreadHead` override on `ComponentContext`; `Message` -> a `MessageUI` override scoped with `WithComponents`; `messageActions` -> removed (message-actions area); `enableDateSeparator` -> `withDateSeparator` on the list. +- Replacement: `` + children (`ThreadHeader`, `MessageList` or `VirtualizedMessageList`, `MessageComposer focus`). Get the instance from `client.threads.ensure({ channel, parentMessage })` / `client.threads.get(...)`, from a `ThreadSlot`, or from workspace navigation. Per prop: list/composer props go on the children; `additionalParentMessageProps` -> the list's own message props, which `MessageList` and `VirtualizedMessageList` also pass to the parent message (`master:src/components/MessageList/hooks/useThreadHead.tsx`), or a `ThreadHead` override on `ComponentContext`; `Message` -> a `MessageUI` override scoped with `WithComponents`; `messageActions` -> removed (message-actions area); `enableDateSeparator` -> `withDateSeparator` on the list. - Evidence: - `release-v14:src/components/Thread/Thread.tsx:26-45` (`ThreadProps`), `:51-66` (reads `ChannelStateContext`, keys on thread id), `:184-207` (renders header, list, composer); `release-v14:src/components/Thread/LegacyThreadContext.ts:8`; `release-v14:src/components/Thread/index.ts:4` - `master:src/components/Thread/Thread.tsx:17-25` (`ThreadProps = PropsWithChildren<{ thread: StreamThread }>`), `:66-113`; `master:src/components/Thread/index.ts:1-3` @@ -2290,7 +2299,7 @@ export const RetryButton = () => { - Evidence: - `release-v14:src/components/Message/types.ts:60` `openThread?: ChannelActionContextValue['openThread']`; `release-v14:src/components/MessageList/MessageList.tsx:505` and `release-v14:src/components/MessageList/VirtualizedMessageList.tsx:88` drill `openThread` - `release-v14:src/components/Message/hooks/useOpenThreadHandler.ts:10` falls back to `useChannelActionContext().openThread` - - `master:src/components/Message/types.ts:53` `openThread?: (message: LocalMessage, event: BaseSyntheticEvent) => void` + - `master:src/components/Message/types.ts:64` `openThread?: (message: LocalMessage, event: BaseSyntheticEvent) => void` - `master:src/components/Message/hooks/useOpenThreadHandler.ts:17` `useWorkspaceNavigation()`, `:32` `void openThread({ channel, message })` - `master:src/components/Message/MessageRepliesCountButton.tsx` falls back to `MessageContext.handleOpenThread` when it has no `onClick` (restored in `fix/v15-dead-surface-cleanup`; before that it called `useWorkspaceNavigation().openThread` directly and ignored `Message.openThread`) - `master:src/context/WorkspaceNavigationContext.tsx:145` `defaultWorkspaceNavigation`, `:157` `openThread: () => undefined`, `:165` `WorkspaceNavigationProvider` @@ -2401,8 +2410,8 @@ export const ChannelWithThread = ({ channel }: { channel: StreamChannel }) => { - `release-v14:src/components/MessageList/MessageList.tsx:541` `loadingMoreForJumpToChannelMessage`, `:545` `loadMore`, `:547` `loadMoreNewer`, `:551` `messageLimit`; `:265`–`:275` forward `messageLimit` to `loadMore` - `release-v14:src/components/MessageList/VirtualizedMessageList.tsx:678` `channelUnreadUiState`, `:701` `hasMore`, `:703` `hasMoreNewer`, `:714` `highlightedMessageId`, `:716` `loadingMore`, `:718` `loadingMoreNewer`, `:720` `loadMore`, `:722` `loadMoreNewer`, `:728` `messageLimit` - `master:src/components/MessageList/MessageList.tsx` `MessageListProps` keeps only `loadingMore` of these; `loadOlderMessages` raises `isLoadingOlder` only when a tailward page can load (`!isLoading && hasMoreTail`), and the loading row renders `(props.loadingMore ?? isLoadingOlder) && `. Before `fix/v15-dead-surface-cleanup` the type kept `hasMore`, `jumpToLatestMessage`, `loadingMoreNewer`, `messageLimit` unread, and the indicator required the prop. - - `master:src/components/MessageList/MessageList.tsx:452` `InternalPaginatorProps`; `master:src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx:18` own props - - `master:src/components/MessageList/VirtualizedMessageList.tsx:288` reads `messages`, `hasMoreNewer`, `isLoading` from `messagePaginator.state` + - `master:src/components/MessageList/MessageList.tsx:435` `InternalPaginatorProps`; `master:src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx:18` own props + - `master:src/components/MessageList/VirtualizedMessageList.tsx:275` reads `messages`, `hasMoreNewer`, `isLoading` from `messagePaginator.state` - `node_modules/stream-chat/dist/types/pagination/paginators/MessageIntervalPaginator.d.ts:153` `messageFocusSignal`, `:279` `jumpToMessage`, `:280` `jumpToTheLatestMessage`; `MessagePaginator.d.ts:87` `unreadStateSnapshot` - commits: `99cbe6704` (PR #3237); `088c795c1 feat(Channel)!: …` (PR #3286, search jump through `messagePaginator.jumpToMessage`) - Docs impact: @@ -2448,15 +2457,15 @@ export const ChannelWithThread = ({ channel }: { channel: StreamChannel }) => { - Replacement: - thread detection: `const thread = useThreadContext(); const isThreadList = !!thread;` (the SDK's own components do this), also in place of `useMessageContext().threadList` - reaction selector dialog id: `ReactionSelector.getDialogId({ messageId })`. Each message list has its own dialog manager, which keeps the channel's and the thread's dialogs for one message apart. - - parent message: both lists render it from `ThreadContext` via `useThreadHead()`. Override it with the `ThreadHead` slot: ``, typed `React.ComponentType`. + - parent message: both lists render it from `ThreadContext` via `useThreadHead`, with the list-wide message props their replies get, except the read state: `returnAllReadData`, `readBy` and `deliveredTo` do not reach it (`MessageStatus` shows no read state inside a thread anyway), so a `ThreadHead` override that needs them sets them itself. Override it with the `ThreadHead` slot: ``, typed `React.ComponentType`. - `VirtuosoContext.head` is now `ReactElement | null` and always present - Evidence: - `release-v14:src/components/MessageList/MessageList.tsx:527` `head?`, `:578` `threadList?` - `release-v14:src/components/MessageList/VirtualizedMessageList.tsx:708` `head?`, `:776` `threadList?` - `release-v14:src/components/TypingIndicator/TypingIndicator.tsx:23` `threadList?`; `release-v14:src/components/MessageList/ScrollToLatestMessageButton.tsx:19` `threadList?` - `master:src/components/TypingIndicator/TypingIndicator.tsx:36`–`:40` props are `isMessageListScrolledToBottom?`, `scrollToBottom?` - - `master:src/components/MessageList/hooks/useThreadHead.tsx:19` `useThreadHead` reads `ThreadHead` from `ComponentContext`; `master:src/context/ComponentContext.tsx:294` `ThreadHead?` - - `master:src/components/MessageList/VirtualizedMessageList.tsx:256` `useThreadHead()`, `:631` `head: threadHead` + - `master:src/components/MessageList/hooks/useThreadHead.tsx:34` `useThreadHead` reads `ThreadHead` from `ComponentContext`; `master:src/context/ComponentContext.tsx:334` `ThreadHead?` + - `master:src/components/MessageList/VirtualizedMessageList.tsx:241` `useThreadHead(messageProps)`, `:602` `head: threadHead` - commits: `c2136f307 refactor!: align Thread with Channel - instance prop, provider, children` (PR #3289, "the `head` prop is removed"); `99cbe6704` (PR #3237) - corpus: `docs/03-ui-cookbook__05-message-composer__07-typing_indicator__1.tsx:14` TS2339 (`threadList` not on `TypingIndicatorProps`) - Docs impact: @@ -2480,9 +2489,9 @@ export const ChannelWithThread = ({ channel }: { channel: StreamChannel }) => { - `release-v14:src/components/MessageList/MessageList.tsx:87` `disableDateSeparator = false`, `:515` prop - `release-v14:src/components/MessageList/VirtualizedMessageList.tsx:209` `disableDateSeparator = true`, `:304` `enableDateSeparator: !disableDateSeparator` - `release-v14:src/components/MessageList/utils.ts:28` `enableDateSeparator?` - - `master:src/components/MessageList/MessageList.tsx:526` `withDateSeparator?`, default `true` in `MessageListWithContext` - - `master:src/components/MessageList/VirtualizedMessageList.tsx:803` `withDateSeparator?`, default `false` (`:252`) - - `master:src/components/MessageList/utils.ts:49` `withDateSeparator?` in `ProcessMessagesContext` + - `master:src/components/MessageList/MessageList.tsx:490` `withDateSeparator?`, default `true` in `MessageListWithContext` + - `master:src/components/MessageList/VirtualizedMessageList.tsx:724` `withDateSeparator?`, default `false` (`:224`) + - `master:src/components/MessageList/utils.ts:47` `withDateSeparator?` in `ProcessMessagesContext` - commit: `c2136f307` (PR #3289) - Docs impact: - `02-ui-components/07-message-list/01-message_list.md`, `02-ui-components/07-message-list/02-virtualized_list.md`, `02-ui-components/08-message/13-date_separator.md` @@ -2594,13 +2603,13 @@ export const Stop = ({ channel }: { channel: Channel }) => { - User impact: compile error on destructuring `canPin`. In untyped code it is `undefined`, so a pin button gated on it never shows. - Old API: - `usePinHandler(message): { canPin: boolean; handlePin }` -- Replacement: `useUserRole(message).canPin` (newly returned). `usePinHandler(message, notifications?)` now returns `{ handlePin }`. +- Replacement: `useMessagePermissions(message).canPin` (newly returned; the hook was `useUserRole`, see BC-092). `usePinHandler(message, notifications?)` now returns `{ handlePin }`. - Evidence: - `release-v14:src/components/Message/hooks/usePinHandler.ts:8`, `:54` `return { canPin, handlePin }` - - `master:src/components/Message/hooks/usePinHandler.ts:43`, `:110` `return { handlePin }`; `master:src/components/Message/hooks/useUserRole.ts:45` `canPin = channelCapabilities.has('pin-message')`, `:62` + - `master:src/components/Message/hooks/usePinHandler.ts:16`, `:115` `return { handlePin }`; `master:src/components/Message/hooks/useMessagePermissions.ts:21` `canPin = channelCapabilities.has('pin-message')`, `:31-44` - commit: `99cbe6704` (PR #3237) - Docs impact: - - `02-ui-components/08-message/04-message_hooks.md` (`usePinHandler`, `useUserRole`) + - `02-ui-components/08-message/04-message_hooks.md` (`usePinHandler`, `useMessagePermissions`) - Example needed: no @@ -2616,7 +2625,7 @@ export const Stop = ({ channel }: { channel: Channel }) => { - Replacement: `VirtuosoContext` now carries a required `channel: Channel`. `head`, `firstUnreadMessageId`, `lastReadMessageId` are `… | null`; `lastReadDate` is `TimestampNS | null`; `loadingMore: boolean`; `unreadMessageCount: number`; `focusedMessageId?` is new. For thread detection use `useThreadContext()` (BC-048); for the message UI use the slots (BC-042). - Evidence: - `release-v14:src/components/MessageList/VirtualizedMessageList.tsx:94`–`:103` (`'head' | 'loadingMore' | 'Message' | 'threadList'` picked into the context), `:108` `VirtuosoContext` - - `master:src/components/MessageList/VirtualizedMessageList.tsx:93`–`:146` + - `master:src/components/MessageList/VirtualizedMessageList.tsx:100`–`:140` - commits: `07664893e` (PR #3268), `b541c45f9` (PR #3288), `c2136f307` (PR #3289), `aaa7fa31e feat!: adopt stream-chat's branded TimestampNS` (PR #3297) - Docs impact: - none found (`VirtuosoContext` is not documented in v14 docs) @@ -2679,7 +2688,7 @@ export const Stop = ({ channel }: { channel: Channel }) => { - Replacement: pass `TimestampNS`, e.g. `message.created_at` or `msToNs(ms)` from `stream-chat`. Related: `ProcessMessagesParams.lastRead` is `TimestampNS | null` (was `Date | null`), and `VirtuosoContext.lastReadDate` is covered by BC-053. - Evidence: - `release-v14:src/components/MessageList/MessageList.tsx:529` `headerPosition?: number`; `release-v14:src/components/MessageList/utils.ts:205` `insertIntro(…, headerPosition?: number)` - - `master:src/components/MessageList/MessageList.tsx:474` `headerPosition?: TimestampNS`; `master:src/components/MessageList/utils.ts:221`–`:223` + - `master:src/components/MessageList/MessageList.tsx:449` `headerPosition?: TimestampNS`; `master:src/components/MessageList/utils.ts:221`–`:223` - `node_modules/stream-chat/dist/types/index.d.ts` exports `msToNs`, `asTimestampNS`, `nowNs` - commits: `cb472c35f feat!: move to timestamps in response models` (PR #3278); `aaa7fa31e` (PR #3297) - Docs impact: @@ -2718,7 +2727,7 @@ export const Stop = ({ channel }: { channel: Channel }) => { - Evidence: - `master:src/context/useChannel.ts` throws when neither a thread nor `ChannelInstanceContext` supplies a channel - `master:src/components/Message/hooks/useOpenThreadHandler.ts:16` and `master:src/components/Message/hooks/useRetryHandler.ts:11` call `useChannel()` during every `Message` render - - `release-v14:src/components/Message/Message.tsx` `const highlighted = highlightedMessageId === message.id` from `useChannelStateContext('Message')`; `master:src/components/Message/Message.tsx` `highlighted={props.highlighted}`; `master:src/components/Message/types.ts:33` + - `release-v14:src/components/Message/Message.tsx` `const highlighted = highlightedMessageId === message.id` from `useChannelStateContext('Message')`; `master:src/components/Message/Message.tsx` `highlighted={props.highlighted}`; `master:src/components/Message/types.ts:44` - examples: `examples.diff:9258`–`:9322` (`ReactionsTab.tsx` swaps the two providers for `ChannelInstanceProvider` with `client.channelManager.ensure(...)`) - corpus: `examples/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx:3`, `:4` TS2724 - commits: `99cbe6704` (PR #3237), `9194215f0` (PR #3267) @@ -2747,6 +2756,348 @@ export const Stop = ({ channel }: { channel: Channel }) => { - none found - Example needed: no + + +### BC-087: `renderText` no longer takes a `mentionedUsers` argument; `RenderTextFunction` is `(text, options)` + +- Status: confirmed +- Area: message text +- User impact: + - Compile error on a three-argument call `renderText(text, mentionedUsers, options)` and on a custom `renderText` prop typed with the old `RenderTextFunction`. + - In untyped code, the options object lands in the second position: a call that still passes `mentionedUsers` second has its options ignored, and its mentions are no longer highlighted. + - A custom `renderText` prop on `MessageList`, `VirtualizedMessageList`, `Message`, `MessageText`, `QuotedMessage` or `QuotedMessagePreview` is now called as `renderText(text, options)`, so one written for the old order reads the options object as `mentionedUsers` and gets `undefined` as `options`. +- Old API: + - `renderText(text?, mentionedUsers?: UserResponse[], options?: RenderTextOptions)` (`mentionedUsers` was `@deprecated`) + - `RenderTextFunction = (text?, mentionedUsers?, options?) => ReactNode` +- Replacement: `renderText(text?, options?)` and `RenderTextFunction = (text?, options?) => ReactNode`. Pass mentions as `options.messageMentionEntities`; it also covers `@channel`, `@here`, roles and user groups. Build the entities from a message with `getRenderTextMentionEntities(message)`. Without `messageMentionEntities` nothing is highlighted as a mention. +- Evidence: + - `release-v14:src/components/Message/renderText/renderText.tsx:118-134` (`mentionedUsers` `@deprecated` on the type and the function); `release-v14:src/components/Message/MessageText.tsx:73` `renderText(messageTextToRender, message.mentioned_users, { … })` + - `master:src/components/Message/renderText/renderText.tsx:111` `RenderTextFunction = (text?: string, options?: RenderTextOptions) => ReactNode`, `:123` `messageMentionEntities = []` + - commit: REACT-1467 (PR #3387) +- Docs impact: + - `02-ui-components/08-message/06-render-text.md` +- Example needed: yes + +Before (v14): + +```tsx +const customRenderText: RenderTextFunction = (text, mentionedUsers, options) => + renderText(text, mentionedUsers, { ...options, customMarkDownRenderers }); +``` + +After (v15): + +```tsx +const customRenderText: RenderTextFunction = (text, options) => + renderText(text, { ...options, customMarkDownRenderers }); +``` + +--- + + + +### BC-088: the `mention` node passed to a custom mention renderer no longer carries `mentionedUser` + +- Status: confirmed +- Area: message text +- User impact: compile error on `node.mentionedUser` in a custom `customMarkDownRenderers.mention` component. In untyped code it is `undefined`, so a renderer that reads the user from it renders without one. +- Old API: + - `MentionProps['node'].mentionedUser: UserResponse` for user mentions, `undefined` otherwise (`@deprecated`) +- Replacement: `node.mentionedEntity` (`RenderTextMentionEntity`). For a user mention, `mentionedEntity.mentionType === 'user'` and the entity carries the user's fields (`id`, `name`, …). `MentionProps['node']` is now `{ mentionedEntity: RenderTextMentionEntity }`. +- Evidence: + - `release-v14:src/components/Message/renderText/componentRenderers/Mention.tsx:7-25` + - `master:src/components/Message/renderText/componentRenderers/Mention.tsx:6-8`; `master:src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts` (`mentionsMarkdownPluginFromLookup` sets only `mentionedEntity`) + - commit: REACT-1467 (PR #3387) +- Docs impact: + - `02-ui-components/08-message/06-render-text.md`, `03-ui-cookbook/04-message/03-mentions_actions.md` +- Example needed: yes + +Before (v14): + +```tsx +const CustomMention = ({ children, node }: MentionProps) => ( + {children} +); +``` + +After (v15): + +```tsx +const CustomMention = ({ children, node: { mentionedEntity } }: MentionProps) => ( + + {children} + +); +``` + +--- + + + +### BC-089: `onMentionsClick` / `onMentionsHover` handlers receive `(event, message)`; the `mentioned_users` argument was removed + +- Status: confirmed +- Area: message / mentions +- User impact: compile error in a handler typed with three parameters. In untyped code the message lands in the second parameter, so a handler that reads `mentionedUsers[0]` gets `undefined` and one that reads the third parameter gets nothing. +- Old API: + - `CustomMentionHandler = (event, mentioned_users: UserResponse[], message: LocalMessage) => void` (`mentioned_users` was `@deprecated`) + - `MentionedUserEventHandler = (event, mentionedUsers: UserResponse[]) => void`, exported and unused by the SDK +- Replacement: `CustomMentionHandler = (event, message: LocalMessage) => void`; `MentionedUserEventHandler` is removed, type such handlers as `CustomMentionHandler`. Read `message.mentioned_users`, `mentioned_channel`, `mentioned_here`, `mentioned_roles` and `mentioned_groups`. To know which mention was clicked, read the target element's `data-mention-id`, `data-mention-type` or `data-user-id`. Enter or Space on the focused message text also calls the handler; its target is the text element, not a mention. +- Evidence: + - `release-v14:src/components/Message/hooks/useMentionsHandler.ts:8-14`, `:18-21` (`MentionedUserEventHandler`), `:39` `fn(event, message?.mentioned_users ?? [], message)` + - `master:src/components/Message/hooks/useMentionsHandler.ts:8-11`, `:20` `handler(event, message)` + - commit: REACT-1467 (PR #3387) +- Docs impact: + - `03-ui-cookbook/04-message/03-mentions_actions.md`, `02-ui-components/07-message-list/01-message_list.md` (`onMentionsClick`, `onMentionsHover`) +- Example needed: yes + +Before (v14): + +```tsx + openProfile(mentionedUsers[0]?.id)} +/> +``` + +After (v15), reading the clicked mention: + +```tsx +const getMentionedUserId = (event: React.BaseSyntheticEvent, message: LocalMessage) => { + // Enter or Space targets the message text, not a mention, so it identifies a user only when + // the message's one mention is a user (no second user, @channel, @here, role or group). + if (event.type === 'keydown') { + const [mention, ...otherMentions] = getRenderTextMentionEntities(message); + if (otherMentions.length || mention?.mentionType !== 'user') return undefined; + return mention.id; + } + return (event.target as HTMLElement).closest('[data-user-id]')?.dataset + .userId; +}; + + { + const userId = getMentionedUserId(event, message); + if (userId) openProfile(userId); + }} +/>; +``` + +--- + + + +### BC-090: `useUserRole` no longer returns `isAdmin`, `isModerator` or `isOwner` + +- Status: confirmed +- Area: message hooks +- User impact: compile error on destructuring the three flags. In untyped code they are `undefined`, so UI gated on them never shows. +- Old API: + - `useUserRole(message).isAdmin`, `.isModerator`, `.isOwner` (all `@deprecated`; derived from `client.user.role` and the deprecated `channel.state.membership.role` / `is_moderator` / `channel_role`) +- Replacement: gate on what the user may do, not on their role. `useMessagePermissions(message)` (renamed from `useUserRole`, see BC-092) returns permission flags built from the channel's capabilities (`canDelete`, `canEdit`, `canFlag`, `canMarkUnread`, `canMute`, `canPin`, `canQuote`, `canReact`, `canReply`, `canSendMessage`, `canBlockUser`, `isMyMessage`); for any other capability read `useChannelCapabilities({ cid: channel.cid })` (a `Set` of capability names). Where a role really is needed, read `client.user?.role` or `channel.state.membership` directly. +- Evidence: + - `release-v14:src/components/Message/hooks/useUserRole.ts:9-31`, `:65-68` + - `master:src/components/Message/hooks/useMessagePermissions.ts:31-44` (returns `can*` flags and `isMyMessage` only) + - commit: REACT-1467 (PR #3387); tracked as REACT-83 +- Docs impact: + - `02-ui-components/08-message/04-message_hooks.md` (`useMessagePermissions`) +- Example needed: no + + + +### BC-091: `VirtualizedMessageList` props `defaultItemHeight`, `overscan` and `scrollSeekPlaceHolder` were removed + +- Status: confirmed +- Area: VirtualizedMessageList +- User impact: compile error on the props. In untyped code they are ignored: a list that set `overscan` or `defaultItemHeight` falls back to the `react-virtuoso` defaults. +- Old API: + - `defaultItemHeight?: number`, `overscan?: number` (default `0`), `scrollSeekPlaceHolder?: ScrollSeekConfiguration & { placeholder }`, all `@deprecated`. `defaultItemHeight` and `scrollSeekPlaceHolder` were spread after `additionalVirtuosoProps`, so they won over it; `overscan` came before it, so `additionalVirtuosoProps.overscan` already won. `scrollSeekPlaceHolder` was forwarded to `react-virtuoso`'s deprecated `scrollSeek` prop, which applied it and logged a deprecation warning. +- Replacement: pass them through `additionalVirtuosoProps`: `additionalVirtuosoProps.defaultItemHeight`, `additionalVirtuosoProps.overscan` (the `react-virtuoso` default is also `0`), and `additionalVirtuosoProps.scrollSeekConfiguration` with the placeholder in `additionalVirtuosoProps.components.ScrollSeekPlaceholder`; this keeps the scroll-seek behavior without the warning. +- Evidence: + - `release-v14:src/components/MessageList/VirtualizedMessageList.tsx:686-689`, `:732-735`, `:744-756`, `:643`, `:650-651` + - `master:src/components/MessageList/VirtualizedMessageList.tsx:633` (`{...overridingVirtuosoProps}`), `:676` `additionalVirtuosoProps` + - commit: REACT-1467 (PR #3387) +- Docs impact: + - `02-ui-components/07-message-list/02-virtualized_list.md` +- Example needed: yes + +Before (v14): + +```tsx + +``` + +After (v15): + +```tsx + +``` + + + +### BC-092: `useUserRole` was renamed to `useMessagePermissions` + +- Status: confirmed +- Area: message hooks +- User impact: compile error on importing `useUserRole`. In untyped code the import is `undefined`, so calling it throws `TypeError: useUserRole is not a function`. +- Old API: + - `useUserRole(message: LocalMessage, disableQuotedMessages?: boolean)` +- Replacement: `useMessagePermissions(message: LocalMessage)`; the `disableQuotedMessages` argument is removed (BC-095). It returns what the current user may do with that message, built from the channel's capabilities and the message's author: `canBlockUser`, `canDelete`, `canEdit`, `canFlag`, `canMarkUnread`, `canMute`, `canPin`, `canQuote`, `canReact`, `canReply`, `canSendMessage` and `isMyMessage`. It returns no role flags (BC-090). +- Evidence: + - `release-v14:src/components/Message/hooks/useUserRole.ts:5`; `release-v14:src/components/Message/hooks/index.ts:12` `export * from './useUserRole'` + - `master:src/components/Message/hooks/useMessagePermissions.ts:5`, `:31-44`; `master:src/components/Message/hooks/index.ts:13` `export * from './useMessagePermissions'` + - commit: REACT-1467 (PR #3387) +- Docs impact: + - `02-ui-components/08-message/04-message_hooks.md` (the `useUserRole` section, renamed to `useMessagePermissions`) + - `03-ui-cookbook` recipes that gate buttons on the hook (custom message actions, pin indicator) +- Example needed: yes + +Before (v14): + +```tsx +import { useUserRole } from 'stream-chat-react'; + +const { canReply } = useUserRole(message); +``` + +After (v15): + +```tsx +import { useMessagePermissions } from 'stream-chat-react'; + +const { canReply } = useMessagePermissions(message); +``` + + + +### BC-093: `MessageContextValue.onMentionsClickMessage` / `onMentionsHoverMessage` and `useMentionsHandler`'s handlers are `undefined` without a handler + +- Status: confirmed +- Area: message context +- User impact: + - compile error (`TS2722`) on calling `onMentionsClickMessage(event)` or `onMentionsHoverMessage(event)` from `useMessageContext()`, or the `onMentionsClick` / `onMentionsHover` that `useMentionsHandler()` returns, without a check. In untyped code the call throws `TypeError: onMentionsClickMessage is not a function` (or the same for the other handler) on a message rendered without `onMentionsClick` / `onMentionsHover`. + - Without `onMentionsClick`, mentions in `MessageText` are no longer interactive: the text is not a separate focus stop, Enter and Space do nothing, and mentions keep the default cursor. +- Old API: + - `onMentionsClickMessage: ReactEventHandler` and `onMentionsHoverMessage: ReactEventHandler`, always set; a no-op when no handler was passed + - `useMentionsHandler(message, handlers)` returned `{ onMentionsClick, onMentionsHover }`, both always functions +- Replacement: the context fields are optional and set only when `onMentionsClick` / `onMentionsHover` was passed to `MessageList` or `Message`. `useMentionsHandler` returns each handler only when the matching one is passed to it, and `undefined` otherwise. Pass them as event props (`onClick={onMentionsClickMessage}`), or call them with `?.`. With `onMentionsClick` set, `MessageText` adds `str-chat__message-text-inner--mentions-clickable`, which gives mentions a pointer cursor. +- Evidence: + - `release-v14:src/context/MessageContext.tsx:63-66`; `release-v14:src/components/Message/Message.tsx:263` `onMentionsClickMessage={onMentionsClick}`; `release-v14:src/components/Message/hooks/useMentionsHandler.ts:55-59` (`|| (() => null)`) + - `master:src/context/MessageContext.tsx:44-47`; `master:src/components/Message/hooks/useMentionsHandler.ts:13-21`, `:28-49`; `master:src/components/Message/Message.tsx:191-192`; `master:src/components/Message/MessageText.tsx:82-83`, `:121-122`, `:125` (`onClick` only when mentions are interactive) + - commit: REACT-1467 (PR #3387) +- Docs impact: + - `02-ui-components/08-message/02-message_context.md` (`onMentionsClickMessage`, `onMentionsHoverMessage`), `02-ui-components/08-message/04-message_hooks.md` (`useMentionsHandler`) +- Example needed: yes + +Before (v14): + +```tsx +const { onMentionsClickMessage } = useMessageContext(); +const onKeyDown = (event) => onMentionsClickMessage(event); +``` + +After (v15): + +```tsx +const { onMentionsClickMessage } = useMessageContext(); +const onKeyDown = (event) => onMentionsClickMessage?.(event); +``` + + + +### BC-094: `mentionsMarkdownPlugin` and `createMentionPluginAndDisplayTextSet` take mention entities only + +- Status: confirmed +- Area: message text +- User impact: compile error on passing a users array (`message.mentioned_users`, `UserResponse[]`). In untyped code a user without `mentionType` matches by name and by ID, and renders with `data-mention-id` but without `data-mention-type="user"` or `data-user-id`, so a click handler that reads `data-user-id` finds nothing and a custom mention renderer's `mentionType === 'user'` check is false. +- Old API: + - `mentionsMarkdownPlugin(mentionedUsers: UserResponse[])` and `mentionsMarkdownPlugin(mentionEntities: RenderTextMentionEntity[])` (overloads) + - `createMentionPluginAndDisplayTextSet(mentionEntitiesOrUsers: RenderTextMentionEntity[] | UserResponse[])` +- Replacement: pass `RenderTextMentionEntity[]`. Build it from a message with `getRenderTextMentionEntities(message)`, which also covers `@channel`, `@here`, roles and user groups; a bare user becomes `{ ...user, mentionType: 'user' }`. +- Evidence: + - `release-v14:src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts:143`, `:270-271`, `:281-291` + - `master:src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts:160`, `:260-262`, `:271-275` + - commit: REACT-1467 (PR #3387) +- Docs impact: + - `02-ui-components/08-message/06-render-text.md` +- Example needed: yes + +Before (v14): + +```tsx +renderText(message.text, { + getRehypePlugins: (plugins) => [ + mentionsMarkdownPlugin(message.mentioned_users ?? []), + ...plugins, + ], +}); +``` + +After (v15): + +```tsx +renderText(message.text, { + getRehypePlugins: (plugins) => [ + mentionsMarkdownPlugin(getRenderTextMentionEntities(message)), + ...plugins, + ], +}); +``` + + + +### BC-095: `disableQuotedMessages` is removed from `MessageProps`, `MessageListProps` and `useMessagePermissions` + +- Status: confirmed +- Area: message / message list +- User impact: compile error on passing `disableQuotedMessages` to `MessageList` or `Message`, or a second argument to `useMessagePermissions`. In untyped code both are ignored. Nothing changes on screen: v14's default `MessageActions` already ignored the flag, because `useBaseMessageActionSetFilter` called `useUserRole(message)` without it, so the Quote action showed whether or not the flag was set. Only `MessageContext.getMessageActions()` honoured it, and no SDK component called that. +- Old API: + - `MessageProps.disableQuotedMessages?: boolean`, also picked into `MessageListProps` + - `useUserRole(message, disableQuotedMessages?)`: `canQuote` was `false` when the flag was set +- Replacement: hide the Quote action with a `messageActionSet` that leaves out the `quote` item (or gives it an `isVisible` rule), installed through the `MessageActions` slot (BC-041). `useMessagePermissions(message).canQuote` is the `quote-message` capability alone. +- Evidence: + - `release-v14:src/components/Message/types.ts:27`; `release-v14:src/components/MessageList/MessageList.tsx:231`, `:497`; `release-v14:src/components/Message/Message.tsx:213`; `release-v14:src/components/Message/hooks/useUserRole.ts:5`, `:49`; `release-v14:src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts:46` `useUserRole(message)` + - `master:src/components/Message/hooks/useMessagePermissions.ts:5`, `:26` + - commit: REACT-1467 (PR #3387) +- Docs impact: + - `02-ui-components/07-message-list/01-message_list.md`, `02-ui-components/08-message/01-message.md` (`disableQuotedMessages`), `02-ui-components/08-message/04-message_hooks.md` (`useMessagePermissions`) +- Example needed: yes + +Before (v14): + +```tsx + +``` + +After (v15): + +```tsx +import { + defaultMessageActionSet, + MessageActions, + MessageList, + WithComponents, +} from 'stream-chat-react'; + +const messageActionSet = defaultMessageActionSet.filter( + // the dropdown-toggle item has no `type`; keep it + (item) => !('type' in item) || item.type !== 'quote', +); + +const MessageActionsWithoutQuote = () => ( + +); + +export const List = () => ( + + + +); +``` + ## Message composer @@ -3268,7 +3619,7 @@ const hasFr = i18n.registeredLanguages.has('fr'); - Evidence: - diff `components/Attachment`, `components/Message`, `components/MessageList`, `components/Poll`, `components/Reactions`, `components/EventComponent`, `context` (`MessageContext.handleDelete` / `handleFetchReactions` / `reactionDetailsSort`) - `master:src/context/MessageContext.tsx` imports `DeleteMessageOptions`, `SortParamRequest` from `stream-chat`; `node_modules/stream-chat/dist/types/types.d.ts:284` `DeleteMessageOptions = NonNullable[1]>`; `gen/models/index.d.ts:8432` `SortParamRequest { direction?, field?, type? }` - - `master:src/components/Message/utils.tsx:42` `isUserMuted(message, mutes?: UserMuteResponse[])` + - `master:src/components/Message/utils.tsx:48` `isUserMuted(message, mutes?: UserMuteResponse[])` - examples: `examples.diff:12306`–`:12307` `onDelete({ deleteForMe, hardDelete })` -> `onDelete({ delete_for_me: deleteForMe, hard: hardDelete })` - commits: `99cbe6704` (PR #3237, "attachment `duration`/`file_size`/`mime_type`/`waveform_data` from `attachment.custom` … `moderation_details` is gone"), `ccccce5e4 fix: adapt to removed stream-chat-js hand-written types` (PR #3274), `cb472c35f` (PR #3278) - corpus: `examples/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx:119` TS2561 (`deleteForMe`); `docs/03-ui-cookbook__06-attachment__03-geolocation_attachment__1.tsx:13`, `:14` TS2339 (location union) @@ -3481,10 +3832,10 @@ Found by the staleness pass of 2026-11-03: commits that landed on `master` after - Replacement: add `!message.poll_id && message.command !== 'giphy'` where the old rule is wanted. - Evidence: - `release-v14:src/components/Message/hooks/useUserRole.ts:35-38` - - `master:src/components/Message/hooks/useUserRole.ts:29-31` + - `master:src/components/Message/hooks/useMessagePermissions.ts:14-16` (`useUserRole` is renamed, see BC-092) - commit: `1c02ba07f` (PR #3365, `BREAKING CHANGE` footer) - Docs impact: - - `02-ui-components/08-message/04-message_hooks.md` (`useUserRole`) + - `02-ui-components/08-message/04-message_hooks.md` (`useMessagePermissions`) - Example needed: no @@ -3498,7 +3849,7 @@ Found by the staleness pass of 2026-11-03: commits that landed on `master` after - `useBaseMessageActionSetFilter` rules for `edit`, `copyMessageText`, `download` (`release-v14:src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts:79-100`) - Replacement: `MessageActionSetItem.isVisible({ isMyMessage, isThreadReply, message })`; copy the default item's `isVisible` into a hand-written item to keep the rule. - Evidence: - - `master:src/components/MessageActions/MessageActions.defaults.tsx:834,845,857` + - `master:src/components/MessageActions/MessageActions.defaults.tsx:830,841,853` - `master:src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts` - commit: `1c02ba07f` (PR #3365, `BREAKING CHANGE` footer) - Docs impact: @@ -3516,7 +3867,7 @@ Found by the staleness pass of 2026-11-03: commits that landed on `master` after - `release-v14:src/components/Message/MessageUI.tsx:134` (`hasSingleAttachment`), `:246-247` (poll, then quote) - Replacement: reorder with a custom `messageBubbleContentSet` installed through the `MessageBubbleContent` slot; target `.str-chat__message--has-poll`. - Evidence: - - `master:src/components/Message/MessageUI.tsx:108-109,130-132` + - `master:src/components/Message/MessageUI.tsx:105-106,126-128` - `master:src/components/Message/MessageBubbleContent.defaults.tsx` - commit: `1c02ba07f` (PR #3365, `BREAKING CHANGE` footer) - Docs impact: @@ -3553,7 +3904,7 @@ Found by the staleness pass of 2026-11-03: commits that landed on `master` after - `release-v14:src/components/Message/hooks/useMuteHandler.ts:22-24`, `useFlagHandler.ts:18-19`, `useMarkUnreadHandler.ts:12-13`, `useDeleteHandler.ts:17-27` - Replacement: catch the rejection in custom UI (`handlePin(event).catch(...)`). `missingUseMuteHandlerParamsWarning` / `missingUseFlagHandlerParameterWarning` are kept and are now the error messages. - Evidence: - - `master:src/components/Message/hooks/usePinHandler.ts:81,109,137`, `useMuteHandler.ts:33,59,83`, `useDeleteHandler.ts:30-32`, `useFlagHandler.ts:18`, `useMarkUnreadHandler.ts:24` + - `master:src/components/Message/hooks/usePinHandler.ts:54,82,110`, `useMuteHandler.ts:33,59,83`, `useDeleteHandler.ts:30-32`, `useFlagHandler.ts:18`, `useMarkUnreadHandler.ts:24` - commits: `d88dbf02a` (PR #3360), `290d2dbaf` (PR #3362); v14 counterpart for pin only: `cf6c8f9ab` (PR #3359, unreleased on `release-v14` at the time of writing) - Docs impact: - `02-ui-components/08-message/04-message_hooks.md` @@ -3588,7 +3939,7 @@ Found by the staleness pass of 2026-11-03: commits that landed on `master` after - `MessageContextValue.endOfGroup`, `.firstOfGroup`, `.groupedByUser` - `str-chat__virtual-message__wrapper--end`, `--first`, `--group` on `.str-chat__message` - Replacement: - - `useMessageContext().groupStyles[0]`: `top`, `middle`, `bottom` or `single` + - `useMessageContext().groupStyles?.[0]`: `top`, `middle`, `bottom` or `single`; `undefined` for a message no list groups (a standalone ``, or the thread's parent message) - CSS: the list item's `.str-chat__li--top`, `--middle`, `--bottom`, `--single`. The default `MessageUI` renders the metadata on every message, and the default CSS hides it on `top` and `middle` items. - Evidence: - `release-v14:src/components/Message/MessageUI.tsx:144` `showMetadata = !groupedByUser || endOfGroup`, with no list setting either flag @@ -3946,7 +4297,10 @@ Non-gated items checked and ruled out: `Chat.isMessageAIGenerated`, `SummarizedM - `usePinHandler.canPin` member-removed, `usePinHandler` return -> BC-052 - `useRetryHandler` -> BC-044 - `useUserRole.canPin` member-added-required -> ruled-out: additive return field + - `useUserRole` removed, `useMessagePermissions` added -> BC-092 + - `Message.disableQuotedMessages`, `MessageProps.disableQuotedMessages`, the `useMessagePermissions` second argument -> BC-095 - components/MessageList + - `MessageList.disableQuotedMessages`, `MessageListProps.disableQuotedMessages`, `SharedMessageProps.disableQuotedMessages` -> BC-095 - `getIsFirstUnreadMessage` -> BC-054 - `insertIntro` -> BC-056 - `MessageList.disableDateSeparator`, `MessageListProps.disableDateSeparator`, `ProcessMessagesParams.enableDateSeparator` -> BC-049 @@ -4162,6 +4516,7 @@ Non-gated items checked and ruled out: `Chat.isMessageAIGenerated`, `SummarizedM ### Diff items ruled out by the coordinator +- `PinEnabledUserRoles` and `PinPermissions` (exported from `usePinHandler.ts` in v15 release candidates, `@deprecated`) were removed by REACT-1467 (PR #3387). Ruled out as a v14 -> v15 change: v14 removed them before v14 stable (`8317b73a2`, PR #3086) and `99cbe6704` (PR #3237) brought them back on `master` by accident, so `release-v14` never exports them. Gate on `useMessagePermissions(message).canPin` instead. - `ThreadContext` / `ThreadContextValue` members reported as `member-added-required`: `configState`, `handleMessageDeleted`, `handleMessageUpdated`, `handleNewReply`, `handleReactionChanged`, `handleRepliesRead`, `handleRepliesUnread`, `handleThreadUpdated`, `handleUserMessagesDeleted`, `initializeConfig`, `markRead`, `messageOperations`, `updateConfig`. Ruled out as LLC (stream-chat v10): `ThreadContextValue` is `Thread | undefined` on both branches, and these are methods and stores added to the `stream-chat` `Thread` class. Only a hand-built `Thread` mock is affected; build it from a real `client.threads` instance instead. - Corpus errors caused only by `stream-chat` v10 types, ruled out as LLC: the attachment `Action` type now requires `text` and `type` (`03-ui-cookbook/06-attachment/01-attachment_actions.md`); `StreamChat.unBlockUser` -> `unblockUser` and `queryUsers`-style id arguments (`04-guides/11-blocking-users.md`, `04-guides/04-typescript_and_custom_data_types.md`); custom fields on `MessageWithChannelResponse` (same page); `ReminderResponse` -> `ReminderResponseData` (`04-guides/12-message-reminders.md`); `MessageResponseBase`, and the narrowed `Event` / `EventPayload` unions (`examples/vite` WebSocket event tooling). Also: the `User` type no longer accepts `name` / `image` (type `userData` as `ClientUser`, as `useCreateChatClient` does; v14 tutorial steps 3-7); `ChannelResponse.name` on `Partial`; channel `members` as `ChannelMemberRequest` objects instead of id strings (tutorial steps 3 and 6); `StreamChat.partialUpdateMessage` and other message methods taking `{ message_id }` objects; `delete_for_me` instead of `deleteForMe` in delete options; `ChannelState.messages` (use `channel.messagePaginator`); `ReactionResponse.custom` now required in hand-built fixtures; `LinkPreview.custom`. diff --git a/examples/cookbook/src/recipes/message-actions/action-bar/CustomMessageUi.tsx b/examples/cookbook/src/recipes/message-actions/action-bar/CustomMessageUi.tsx index 3a5aea59c4..6d998cfb08 100644 --- a/examples/cookbook/src/recipes/message-actions/action-bar/CustomMessageUi.tsx +++ b/examples/cookbook/src/recipes/message-actions/action-bar/CustomMessageUi.tsx @@ -6,8 +6,8 @@ import { MessageText, useChatContext, useMessageContext, + useMessagePermissions, useThreadContext, - useUserRole, } from 'stream-chat-react'; import './CustomMessageUi.css'; @@ -63,6 +63,9 @@ const CustomMessageUiMetadata = () => { readBy = [], } = useMessageContext(); const { client } = useChatContext(); + // Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo` + // say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a + // message shown in a thread is never read, delivered or sent here. const isInThread = !!useThreadContext(); const createdAt = convertTimestampToDate(message.created_at); @@ -107,8 +110,10 @@ const CustomMessageUiMetadata = () => { const CustomMessageUiActions = () => { const { handleDelete, handleFlag, handleOpenThread, handlePin, message } = useMessageContext(); + // Pinning and replying work on the channel list, so the thread's messages, the parent + // included, offer neither, as in the SDK's default message actions. const isInThread = !!useThreadContext(); - const { canDelete, canFlag, canPin, canReply } = useUserRole(message); + const { canDelete, canFlag, canPin, canReply } = useMessagePermissions(message); return (
diff --git a/examples/cookbook/src/recipes/message-actions/reactions/CustomMessageUi.tsx b/examples/cookbook/src/recipes/message-actions/reactions/CustomMessageUi.tsx index 7f2d6c0739..9624b73c58 100644 --- a/examples/cookbook/src/recipes/message-actions/reactions/CustomMessageUi.tsx +++ b/examples/cookbook/src/recipes/message-actions/reactions/CustomMessageUi.tsx @@ -8,8 +8,8 @@ import { useChatContext, useComponentContext, useMessageContext, + useMessagePermissions, useThreadContext, - useUserRole, } from 'stream-chat-react'; import './CustomMessageUi.css'; @@ -65,6 +65,9 @@ const CustomMessageUiMetadata = () => { readBy = [], } = useMessageContext(); const { client } = useChatContext(); + // Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo` + // say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a + // message shown in a thread is never read, delivered or sent here. const isInThread = !!useThreadContext(); const createdAt = convertTimestampToDate(message.created_at); @@ -115,8 +118,11 @@ const CustomMessageUiActions = () => { handleReaction, message, } = useMessageContext(); + // Pinning and replying work on the channel list, so the thread's messages, the parent + // included, offer neither, as in the SDK's default message actions. const isInThread = !!useThreadContext(); - const { canDelete, canFlag, canPin, canReact, canReply } = useUserRole(message); + const { canDelete, canFlag, canPin, canReact, canReply } = + useMessagePermissions(message); const { reactionOptions } = useComponentContext(); const quickReactions = reactionOptions && 'quick' in reactionOptions ? reactionOptions.quick : {}; diff --git a/examples/cookbook/src/recipes/message-list/custom-message/CustomMessageUi.tsx b/examples/cookbook/src/recipes/message-list/custom-message/CustomMessageUi.tsx index 8cda71bd03..67d1fe6556 100644 --- a/examples/cookbook/src/recipes/message-list/custom-message/CustomMessageUi.tsx +++ b/examples/cookbook/src/recipes/message-list/custom-message/CustomMessageUi.tsx @@ -56,6 +56,9 @@ const getMessageStatus = ({ const CustomMessageUiMetadata = () => { const { deliveredTo = [], isMyMessage, message, readBy = [] } = useMessageContext(); const { client } = useChatContext(); + // Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo` + // say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a + // message shown in a thread is never read, delivered or sent here. const isInThread = !!useThreadContext(); const createdAt = convertTimestampToDate(message.created_at); diff --git a/examples/cookbook/src/recipes/message-list/custom-message/steps/metadata/CustomMessageUi.tsx b/examples/cookbook/src/recipes/message-list/custom-message/steps/metadata/CustomMessageUi.tsx index cda6ac6a9a..92aaed0f4b 100644 --- a/examples/cookbook/src/recipes/message-list/custom-message/steps/metadata/CustomMessageUi.tsx +++ b/examples/cookbook/src/recipes/message-list/custom-message/steps/metadata/CustomMessageUi.tsx @@ -55,6 +55,9 @@ const getMessageStatus = ({ const CustomMessageUiMetadata = () => { const { deliveredTo = [], isMyMessage, message, readBy = [] } = useMessageContext(); const { client } = useChatContext(); + // Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo` + // say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a + // message shown in a thread is never read, delivered or sent here. const isInThread = !!useThreadContext(); const createdAt = convertTimestampToDate(message.created_at); diff --git a/examples/cookbook/src/recipes/message-list/pin-indicator/PinnedByIndicator.tsx b/examples/cookbook/src/recipes/message-list/pin-indicator/PinnedByIndicator.tsx index 1c47e2387d..512fdcf5e1 100644 --- a/examples/cookbook/src/recipes/message-list/pin-indicator/PinnedByIndicator.tsx +++ b/examples/cookbook/src/recipes/message-list/pin-indicator/PinnedByIndicator.tsx @@ -3,7 +3,7 @@ import { convertTimestampToDate } from 'stream-chat'; import { useChatContext, useMessageContext, - useUserRole, + useMessagePermissions, WithComponents, } from 'stream-chat-react'; import './PinnedByIndicator.css'; @@ -11,7 +11,7 @@ import './PinnedByIndicator.css'; const PinnedByIndicator = () => { const { client } = useChatContext(); const { handlePin, message } = useMessageContext(); - const { canPin } = useUserRole(message); + const { canPin } = useMessagePermissions(message); if (!message.pinned) return null; diff --git a/examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.tsx b/examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.tsx index 90bf8a59e7..ae15c56e6c 100644 --- a/examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.tsx +++ b/examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.tsx @@ -21,15 +21,16 @@ const readersSelector = ({ readersByMessageId }: MessageReceiptsSnapshot) => ({ const ReadReceiptAvatars = () => { const channel = useChannel(); const { client } = useChatContext(); - const { message } = useMessageContext(); - const isInThread = !!useThreadContext(); + const { initialMessage, message } = useMessageContext(); + const isThreadReply = !!useThreadContext() && !initialMessage; const { readersByMessageId } = useStateStore( channel.messageReceiptsTracker.snapshotStore, readersSelector, ); - // Read positions belong to the channel's main list, so a thread shows none. - if (isInThread) return null; + // Read positions belong to the channel's main list, so thread replies show none. The thread + // parent is a channel message and keeps its readers. + if (isThreadReply) return null; const readers = (readersByMessageId[message.id] ?? []).filter( (user) => user.id !== client.userId, diff --git a/examples/cookbook/src/recipes/search/mentions-inbox/MentionsInbox.tsx b/examples/cookbook/src/recipes/search/mentions-inbox/MentionsInbox.tsx index b6442cf847..64f118bb32 100644 --- a/examples/cookbook/src/recipes/search/mentions-inbox/MentionsInbox.tsx +++ b/examples/cookbook/src/recipes/search/mentions-inbox/MentionsInbox.tsx @@ -113,7 +113,7 @@ const Mention = ({ autoFocus, message }: MentionProps) => { /> - {renderText(localMessage.text, undefined, { + {renderText(localMessage.text, { allowedTagNames, messageMentionEntities: getRenderTextMentionEntities(localMessage), })} diff --git a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx index dede5bda9e..f97291921a 100644 --- a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx @@ -24,8 +24,8 @@ export const ReactionsTab = ({ close }: ReactionsTabProps) => { const componentContext = useComponentContext(); const { client } = useChatContext(); // A real Channel, never queried or watched: `Message` reaches `channel.state` through - // `useUserRole` -> `useChannelCapabilities`, which subscribes to it as a StateStore. A plain - // object shaped like channel state crashes there, which is what this preview used to pass. + // `useMessagePermissions` -> `useChannelCapabilities`, which subscribes to it as a + // StateStore. A plain object shaped like channel state crashes there. const previewChannel = useMemo( () => client.channel('messaging', 'reactions-preview'), [client], diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx index e7002c7577..1263c1aa04 100644 --- a/examples/vite/src/ChatLayout/Panels.tsx +++ b/examples/vite/src/ChatLayout/Panels.tsx @@ -1,10 +1,12 @@ import clsx from 'clsx'; import type { ChannelMemberResponse, + LocalMessage, Channel as StreamChannel, Thread as ThreadType, } from 'stream-chat'; import { + type BaseSyntheticEvent, type MouseEvent, type PropsWithChildren, useCallback, @@ -19,6 +21,7 @@ import { ChannelHeader, ChannelListItem, MessageComposerUI as DefaultMessageComposerUI, + getRenderTextMentionEntities, MessageComposer, MessageList, type MessageListProps, @@ -80,6 +83,20 @@ export const THREAD_LIST_TARGET_ID = 'app-thread-list'; export const CHANNELS_SELECTOR_BUTTON_TARGET_QUERY = '[id^="str-chat__chat-view-"][id$="-tab-channels"]'; +/** + * The user behind the clicked mention. Enter or Space targets the message text, not a mention, so + * it identifies a user only when the message's one mention is a user. + */ +const getMentionedUserId = (event: BaseSyntheticEvent, message: LocalMessage) => { + if (event.type === 'keydown') { + const [mention, ...otherMentions] = getRenderTextMentionEntities(message); + if (otherMentions.length || mention?.mentionType !== 'user') return undefined; + return mention.id; + } + return (event.target as HTMLElement).closest('[data-user-id]')?.dataset + .userId; +}; + // 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 = () => @@ -192,8 +209,8 @@ const ChannelThreadPanel = ({ - onOpenMemberDetail(mentionedUsers[0]?.id) + onMentionsClick={(event, message) => + onOpenMemberDetail(getMentionedUserId(event, message)) } onUserClick={(_event, user) => onOpenMemberDetail(user.id)} /> @@ -459,8 +476,8 @@ const ResponsiveChannelPanels = ({ mainChannel }: { mainChannel?: StreamChannel ) : ( - openMemberDetail(mentionedUsers[0]?.id) + onMentionsClick={(event, message) => + openMemberDetail(getMentionedUserId(event, message)) } onUserClick={(_event, user) => openMemberDetail(user.id)} returnAllReadData diff --git a/examples/vite/src/CustomMessageUi/variants.tsx b/examples/vite/src/CustomMessageUi/variants.tsx index 0daecb58fd..daf1ee0056 100644 --- a/examples/vite/src/CustomMessageUi/variants.tsx +++ b/examples/vite/src/CustomMessageUi/variants.tsx @@ -154,6 +154,9 @@ const CustomMessageUiMetadata = ({ message, readBy = [], } = useMessageContext(); + // Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo` + // say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a + // message shown in a thread is never read, delivered or sent here. const isInThread = !!useThreadContext(); const { client } = useChatContext(); const { diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 932a0e5b72..91dadf58df 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -6,6 +6,7 @@ import { useFlagHandler, useMarkUnreadHandler, useMentionsHandler, + useMessagePermissions, useMuteHandler, useOpenThreadHandler, usePinHandler, @@ -13,9 +14,10 @@ import { useReactionsFetcher, useRetryHandler, useUserHandler, - useUserRole, } from './hooks'; +import type { UserEventHandler } from './hooks'; import { areMessagePropsEqual } from './utils'; +import { useStableCallback } from '../../utils'; import type { LocalMessage } from 'stream-chat'; import type { MessageContextValue } from '../../context'; @@ -50,7 +52,7 @@ type MessageContextPropsToPick = type MessageWithContextProps = Omit & Pick & { - userRoles: ReturnType; + permissions: ReturnType; }; const MessageWithContext = (props: MessageWithContextProps) => { @@ -58,7 +60,7 @@ const MessageWithContext = (props: MessageWithContextProps) => { message, onUserClick: propOnUserClick, onUserHover: propOnUserHover, - userRoles, + permissions, } = props; const { isMessageAIGenerated } = useChatContext(); @@ -77,12 +79,12 @@ const MessageWithContext = (props: MessageWithContextProps) => { onUserHoverHandler: propOnUserHover, }); - const { isMyMessage } = userRoles; + const { isMyMessage } = permissions; const { onUserClick: onUserClickPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars onUserHover: onUserHoverPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars - userRoles: userRolesPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars + permissions: permissionsPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars ...rest } = props; @@ -115,10 +117,11 @@ const MemoizedMessage = React.memo( export const Message = (props: MessageProps) => { const { closeReactionSelectorOnClick, - disableQuotedMessages, message, onMentionsClick: propOnMentionsClick, onMentionsHover: propOnMentionsHover, + onUserClick: propOnUserClick, + onUserHover: propOnUserHover, openThread: propOpenThread, reactionDetailsSort, sortReactions, @@ -126,7 +129,7 @@ export const Message = (props: MessageProps) => { const handleAction = useActionHandler(message); const handleReaction = useReactionHandler(message); - const userRoles = useUserRole(message, disableQuotedMessages); + const permissions = useMessagePermissions(message); const handleFetchReactions = useReactionsFetcher(message); @@ -142,6 +145,14 @@ export const Message = (props: MessageProps) => { onMentionsClick: propOnMentionsClick, onMentionsHover: propOnMentionsHover, }); + // The memoized message ignores handler identity, so a swapped `onUserClick` / `onUserHover` + // reaches it through these stable wrappers, which always call the latest prop. + const onUserClick: UserEventHandler = useStableCallback((event, user) => + propOnUserClick?.(event, user), + ); + const onUserHover: UserEventHandler = useStableCallback((event, user) => + propOnUserHover?.(event, user), + ); const { handlePin } = usePinHandler(message); const handleOpenThread = useOpenThreadHandler(message, propOpenThread); @@ -157,7 +168,6 @@ export const Message = (props: MessageProps) => { autoscrollToBottom={props.autoscrollToBottom} closeReactionSelectorOnClick={closeReactionSelectorOnClick} deliveredTo={props.deliveredTo} - disableQuotedMessages={props.disableQuotedMessages} formatDate={props.formatDate} groupStyles={props.groupStyles} handleAction={handleAction} @@ -178,15 +188,16 @@ export const Message = (props: MessageProps) => { messageListRect={props.messageListRect} onMentionsClickMessage={onMentionsClick} onMentionsHoverMessage={onMentionsHover} - onUserClick={props.onUserClick} - onUserHover={props.onUserHover} + onUserClick={onUserClick} + onUserHover={onUserHover} + permissions={permissions} reactionDetailsSort={reactionDetailsSort} readBy={props.readBy} renderText={props.renderText} returnAllReadData={props.returnAllReadData} + showAvatar={props.showAvatar} sortReactions={sortReactions} unsafeHTML={props.unsafeHTML} - userRoles={userRoles} /> ); }; diff --git a/src/components/Message/MessageBlocked.tsx b/src/components/Message/MessageBlocked.tsx index 6e1dd5bf1e..f5446cab15 100644 --- a/src/components/Message/MessageBlocked.tsx +++ b/src/components/Message/MessageBlocked.tsx @@ -1,7 +1,7 @@ import React from 'react'; import clsx from 'clsx'; -import { useUserRole } from './hooks/useUserRole'; +import { useMessagePermissions } from './hooks/useMessagePermissions'; import { useTranslationContext } from '../../context/TranslationContext'; import { useMessageContext } from '../../context'; @@ -9,7 +9,7 @@ export const MessageBlocked = () => { const { message } = useMessageContext(); const { t } = useTranslationContext(); - const { isMyMessage } = useUserRole(message); + const { isMyMessage } = useMessagePermissions(message); const messageClasses = clsx( 'str-chat__message str-chat__message--blocked', diff --git a/src/components/Message/MessageText.tsx b/src/components/Message/MessageText.tsx index 91c534dedf..48eee05120 100644 --- a/src/components/Message/MessageText.tsx +++ b/src/components/Message/MessageText.tsx @@ -70,10 +70,10 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { const messageText = useMemo( () => - renderText(messageTextToRender, message.mentioned_users, { + renderText(messageTextToRender, { messageMentionEntities: renderTextMentionEntities, }), - [message.mentioned_users, messageTextToRender, renderText, renderTextMentionEntities], + [messageTextToRender, renderText, renderTextMentionEntities], ); const wrapperClass = customWrapperClass || 'str-chat__message-text'; @@ -97,7 +97,7 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { } event.preventDefault(); - onMentionsClickMessage(event); + onMentionsClickMessage?.(event); }; if (!messageTextToRender) return null; @@ -115,12 +115,14 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => {
, + reactionOptions: defaultReactionOptions, + ...components, + }; + const renderTree = (messageProps: typeof props) => ( - ( - - ), - reactionOptions: defaultReactionOptions, - ...components, - }} - > + - , + ); + + const result = renderer(renderTree(props)); + return { + ...result, + rerenderWithProps: (nextProps: typeof props) => + result.rerender(renderTree(nextProps)), + }; } function renderComponentWithMessage( @@ -388,12 +394,8 @@ describe(' component', () => { message, }); - context.onMentionsClickMessage(mouseEventMock); - expect(onMentionsClick).toHaveBeenCalledWith( - mouseEventMock, - message.mentioned_users, - message, - ); + context.onMentionsClickMessage?.(mouseEventMock); + expect(onMentionsClick).toHaveBeenCalledWith(mouseEventMock, message); }); it('should trigger channel mentions hover on mentions hover', async () => { @@ -409,12 +411,55 @@ describe(' component', () => { message, }); - context.onMentionsHoverMessage(mouseEventMock); - expect(onMentionsHover).toHaveBeenCalledWith( - mouseEventMock, - message.mentioned_users, + context.onMentionsHoverMessage?.(mouseEventMock); + expect(onMentionsHover).toHaveBeenCalledWith(mouseEventMock, message); + }); + + it('should leave the mention handlers undefined when none is set', async () => { + const message = generateMessage({ mentioned_users: [bob] }); + let context: MessageContextValue; + + await renderComponent({ + contextCallback: (ctx) => { + context = ctx; + }, message, - ); + }); + + expect(context.onMentionsClickMessage).toBeUndefined(); + expect(context.onMentionsHoverMessage).toBeUndefined(); + }); + + it('should provide showAvatar and its later changes in the message context', async () => { + const message = generateMessage({ user: alice }); + let context: MessageContextValue; + + const { rerenderWithProps } = await renderComponent({ + contextCallback: (ctx) => { + context = ctx; + }, + message, + props: { showAvatar: 'incoming' }, + }); + expect(context.showAvatar).toBe('incoming'); + + rerenderWithProps({ showAvatar: false }); + expect(context.showAvatar).toBe(false); + }); + + it('should update the default MessageUI avatar class when showAvatar changes', async () => { + const message = generateMessage({ user: bob }); + + const { container, rerenderWithProps } = await renderComponent({ + components: { MessageUI }, + message, + props: { showAvatar: 'incoming' }, + }); + const messageElement = () => container.querySelector('.str-chat__message'); + expect(messageElement()).toHaveClass('str-chat__message--with-avatar'); + + rerenderWithProps({ showAvatar: false }); + expect(messageElement()).not.toHaveClass('str-chat__message--with-avatar'); }); it('should trigger channel onUserClick handler when a user element is clicked', async () => { @@ -434,6 +479,39 @@ describe(' component', () => { expect(onUserClickMock).toHaveBeenCalledWith(mouseEventMock, message.user); }); + it.each([ + ['onUserClick', 'onUserClick', 'user'], + ['onUserHover', 'onUserHover', 'user'], + ['onMentionsClick', 'onMentionsClickMessage', 'message'], + ['onMentionsHover', 'onMentionsHoverMessage', 'message'], + ] as const)( + 'should call the latest %s after the handler is swapped', + async (propName, contextName, secondArgument) => { + const message = generateMessage({ mentioned_users: [bob], user: bob }); + const firstHandler = vi.fn(); + const nextHandler = vi.fn(); + let context: MessageContextValue; + + const { rerenderWithProps } = await renderComponent({ + contextCallback: (ctx) => { + context = ctx; + }, + message, + props: { [propName]: firstHandler }, + }); + const handlerBeforeSwap = context[contextName]; + + rerenderWithProps({ [propName]: nextHandler }); + handlerBeforeSwap?.(mouseEventMock); + + expect(firstHandler).not.toHaveBeenCalled(); + expect(nextHandler).toHaveBeenCalledWith( + mouseEventMock, + secondArgument === 'user' ? message.user : message, + ); + }, + ); + it('should trigger channel onUserHover handler when a user element is hovered', async () => { const message = generateMessage({ user: bob }); const onUserHoverMock = vi.fn(() => {}); diff --git a/src/components/Message/__tests__/MessageText.test.tsx b/src/components/Message/__tests__/MessageText.test.tsx index 61e2d4ff1d..e8e3d6e7f6 100644 --- a/src/components/Message/__tests__/MessageText.test.tsx +++ b/src/components/Message/__tests__/MessageText.test.tsx @@ -73,7 +73,15 @@ function generateAliceMessage(messageOptions) { }); } -async function renderMessageText({ customProps = {} } = {}) { +type MentionHandlers = Pick; + +async function renderMessageText({ + customProps = {}, + mentionHandlers = { + onMentionsClick: onMentionsClickMock, + onMentionsHover: onMentionsHoverMock, + } as MentionHandlers, +} = {}) { // Renders under real / providers. Mention handlers are props. const { channels: [channel], @@ -83,7 +91,7 @@ async function renderMessageText({ customProps = {} } = {}) { const renderMessageTextDirectly = 'customInnerClass' in customProps || 'customWrapperClass' in customProps; - return render( + const renderTree = (handlers: MentionHandlers) => ( @@ -128,8 +136,15 @@ async function renderMessageText({ customProps = {} } = {}) { - , + ); + + const result = render(renderTree(mentionHandlers)); + return { + ...result, + rerenderWithMentionHandlers: (handlers: MentionHandlers) => + result.rerender(renderTree(handlers)), + }; } const messageTextTestId = 'message-text-inner-wrapper'; @@ -187,10 +202,7 @@ describe('', () => { it('should handle message mention mouse hover event', async () => { const message = generateAliceMessage({ mentioned_users: [bob] }); const { container, getByTestId } = await renderMessageText({ - customProps: { - message, - onMentionsHoverMessage: onMentionsHoverMock, - }, + customProps: { message }, }); expect(onMentionsHoverMock).not.toHaveBeenCalled(); fireEvent.mouseOver(getByTestId(messageTextTestId)); @@ -214,13 +226,75 @@ describe('', () => { expect(results).toHaveNoViolations(); }); + it('should mark mentions as clickable when an onMentionsClick handler is set', async () => { + const message = generateAliceMessage({ mentioned_users: [bob] }); + const { getByTestId } = await renderMessageText({ customProps: { message } }); + + expect(getByTestId(messageTextTestId)).toHaveClass( + 'str-chat__message-text-inner--mentions-clickable', + ); + }); + + it('should keep mentions non-interactive without an onMentionsClick handler', async () => { + const message = generateAliceMessage({ mentioned_users: [bob] }); + const { container, getByTestId } = await renderMessageText({ + customProps: { message }, + mentionHandlers: {}, + }); + + const innerWrapper = getByTestId(messageTextTestId); + const outerWrapper = innerWrapper.parentElement; + + expect(innerWrapper).not.toHaveClass( + 'str-chat__message-text-inner--mentions-clickable', + ); + expect(outerWrapper).toHaveAttribute('tabindex', '0'); + expect(innerWrapper).not.toHaveAttribute('tabindex'); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should make mentions clickable when onMentionsClick is set after the first render', async () => { + const message = generateAliceMessage({ mentioned_users: [bob] }); + const { getByTestId, rerenderWithMentionHandlers } = await renderMessageText({ + customProps: { message }, + mentionHandlers: {}, + }); + expect(getByTestId(messageTextTestId)).not.toHaveClass( + 'str-chat__message-text-inner--mentions-clickable', + ); + + rerenderWithMentionHandlers({ onMentionsClick: onMentionsClickMock }); + + expect(getByTestId(messageTextTestId)).toHaveClass( + 'str-chat__message-text-inner--mentions-clickable', + ); + fireEvent.click(getByTestId(messageTextTestId)); + expect(onMentionsClickMock).toHaveBeenCalledTimes(1); + }); + + it('should fire a hover-only mention handler without making mentions focusable', async () => { + const message = generateAliceMessage({ mentioned_users: [bob] }); + const { getByTestId } = await renderMessageText({ + customProps: { message }, + mentionHandlers: { onMentionsHover: onMentionsHoverMock }, + }); + + const innerWrapper = getByTestId(messageTextTestId); + fireEvent.mouseOver(innerWrapper); + + expect(onMentionsHoverMock).toHaveBeenCalledTimes(1); + expect(innerWrapper).not.toHaveAttribute('tabindex'); + expect(innerWrapper.parentElement).toHaveAttribute('tabindex', '0'); + expect(innerWrapper).not.toHaveClass( + 'str-chat__message-text-inner--mentions-clickable', + ); + }); + it('should handle message mention mouse click event', async () => { const message = generateAliceMessage({ mentioned_users: [bob] }); const { container, getByTestId } = await renderMessageText({ - customProps: { - message, - onMentionsClickMessage: onMentionsClickMock, - }, + customProps: { message }, }); expect(onMentionsClickMock).not.toHaveBeenCalled(); fireEvent.click(getByTestId(messageTextTestId)); @@ -414,7 +488,7 @@ describe('', () => { expect(onMentionsClickMock).not.toHaveBeenCalled(); fireEvent.keyDown(getByTestId(messageTextTestId), { key: 'Enter' }); - expect(onMentionsClickMock).toHaveBeenCalledWith(expect.anything(), [], message); + expect(onMentionsClickMock).toHaveBeenCalledWith(expect.anything(), message); const results = await axe(container); expect(results).toHaveNoViolations(); diff --git a/src/components/Message/__tests__/QuotedMessage.test.tsx b/src/components/Message/__tests__/QuotedMessage.test.tsx index 193ce80823..2a701b4cde 100644 --- a/src/components/Message/__tests__/QuotedMessage.test.tsx +++ b/src/components/Message/__tests__/QuotedMessage.test.tsx @@ -189,7 +189,9 @@ describe('QuotedMessage', () => { }, }); - expect(fn).toHaveBeenCalled(); + expect(fn).toHaveBeenCalledWith(messageText, { + messageMentionEntities: expect.any(Array), + }); expect((await findByTestId('quoted-message-text')).textContent).toEqual(messageText); const results = await axe(container); expect(results).toHaveNoViolations(); diff --git a/src/components/Message/__tests__/utils.test.ts b/src/components/Message/__tests__/utils.test.ts index 2eaca677ec..094d4ae8c5 100644 --- a/src/components/Message/__tests__/utils.test.ts +++ b/src/components/Message/__tests__/utils.test.ts @@ -98,6 +98,26 @@ describe('Message utils', () => { expect(shouldUpdate).toBe(false); }); + describe.each(['onMentionsClickMessage', 'onMentionsHoverMessage'] as const)( + '%s', + (handlerName) => { + type ComparedProps = Parameters[0]; + const message = generateMessage(); + const withHandler = (handler = vi.fn()) => + fromPartial({ [handlerName]: handler, message }); + const withoutHandler = fromPartial({ message }); + + it('should update when the handler is set or removed', () => { + expect(areMessagePropsEqual(withHandler(), withoutHandler)).toBe(false); + expect(areMessagePropsEqual(withoutHandler, withHandler())).toBe(false); + }); + + it('should not update when only the handler identity changes', () => { + expect(areMessagePropsEqual(withHandler(), withHandler())).toBe(true); + }); + }, + ); + // `updated_at` is a number, not a fresh `Date` per ingest, so a re-ingest does not force a // repaint on its own and the compared-field list is load-bearing. These pin the fields it has // to cover. diff --git a/src/components/Message/hooks/__tests__/useMentionsHandler.test.tsx b/src/components/Message/hooks/__tests__/useMentionsHandler.test.tsx index e5da2a3305..34d1900b95 100644 --- a/src/components/Message/hooks/__tests__/useMentionsHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useMentionsHandler.test.tsx @@ -40,28 +40,25 @@ describe('useMentionsHandler custom hooks', () => { }); }); - it("should call onMentionsClick with message's mentioned users when user clicks on a mention", () => { + it('should call onMentionsClick with the message when user clicks on a mention', () => { const alice = generateUser(); const bob = generateUser(); const mentioned_users = [alice, bob]; const message = generateMessage({ mentioned_users }); const { onMentionsClick } = renderUseMentionsHandlerHook(message); onMentionsClick(mouseEventMock); - expect(onMentionsClickMock).toHaveBeenCalledWith( - mouseEventMock, - mentioned_users, - message, - ); + expect(onMentionsClickMock).toHaveBeenCalledWith(mouseEventMock, message); }); - it('should not call onMentionsClick when it is not defined', () => { - const alice = generateUser(); - const bob = generateUser(); - const mentioned_users = [alice, bob]; - const message = generateMessage({ mentioned_users }); - const { onMentionsClick } = renderUseMentionsHandlerHook(message, {}, null); - onMentionsClick(mouseEventMock); - expect(onMentionsClickMock).not.toHaveBeenCalled(); + it('should return no click handler when onMentionsClick is not set', () => { + const message = generateMessage({ mentioned_users: [generateUser()] }); + const { onMentionsClick, onMentionsHover } = renderUseMentionsHandlerHook( + message, + {}, + null, + ); + expect(onMentionsClick).toBeUndefined(); + expect(onMentionsHover).toEqual(expect.any(Function)); }); it('should not call onMentionsClick when it message has no mentioned users set', () => { @@ -88,43 +85,36 @@ describe('useMentionsHandler custom hooks', () => { ], }, ], - ])('should call onMentionsClick with an empty user list for a %s', (_, mention) => { + ])('should call onMentionsClick for a %s', (_, mention) => { const message = generateMessage({ ...mention, mentioned_users: undefined, }); const { onMentionsClick } = renderUseMentionsHandlerHook(message); onMentionsClick(mouseEventMock); - expect(onMentionsClickMock).toHaveBeenCalledWith(mouseEventMock, [], message); + expect(onMentionsClickMock).toHaveBeenCalledWith(mouseEventMock, message); }); - it("should call onMentionsHover with message's mentioned users when user hovers on a mention", () => { + it('should call onMentionsHover with the message when user hovers on a mention', () => { const alice = generateUser(); const bob = generateUser(); const mentioned_users = [alice, bob]; const message = generateMessage({ mentioned_users }); const { onMentionsHover } = renderUseMentionsHandlerHook(message); onMentionsHover(mouseEventMock); - expect(onMentionsHoverMock).toHaveBeenCalledWith( - mouseEventMock, - mentioned_users, - message, - ); + expect(onMentionsHoverMock).toHaveBeenCalledWith(mouseEventMock, message); }); - it('should not call onMentionsHover when it is not defined', () => { - const alice = generateUser(); - const bob = generateUser(); - const mentioned_users = [alice, bob]; - const message = generateMessage({ mentioned_users }); - const { onMentionsHover } = renderUseMentionsHandlerHook( + it('should return no hover handler when onMentionsHover is not set', () => { + const message = generateMessage({ mentioned_users: [generateUser()] }); + const { onMentionsClick, onMentionsHover } = renderUseMentionsHandlerHook( message, {}, onMentionsClickMock, null, ); - onMentionsHover(mouseEventMock); - expect(onMentionsHoverMock).not.toHaveBeenCalled(); + expect(onMentionsHover).toBeUndefined(); + expect(onMentionsClick).toEqual(expect.any(Function)); }); it('should not call onMentionsHover when message has no mentioned users set', () => { @@ -143,11 +133,7 @@ describe('useMentionsHandler custom hooks', () => { onMentionsHover: onMentionsHoverHandler, }); onMentionsHover(mouseEventMock); - expect(onMentionsHoverHandler).toHaveBeenCalledWith( - mouseEventMock, - mentioned_users, - message, - ); + expect(onMentionsHoverHandler).toHaveBeenCalledWith(mouseEventMock, message); }); it('should call the custom mention click handler when one is set', () => { @@ -159,10 +145,38 @@ describe('useMentionsHandler custom hooks', () => { onMentionsClick: onMentionsClickHandler, }); onMentionsClick(mouseEventMock); - expect(onMentionsClickHandler).toHaveBeenCalledWith( - mouseEventMock, - mentioned_users, - message, + expect(onMentionsClickHandler).toHaveBeenCalledWith(mouseEventMock, message); + }); + + it('should keep its handlers stable and call the latest custom handlers with the latest message', () => { + const message = generateMessage({ mentioned_users: [generateUser()] }); + const nextMessage = generateMessage({ mentioned_users: [generateUser()] }); + const event = fromPartial(mouseEventMock); + const firstClick = vi.fn(); + const nextClick = vi.fn(); + const nextHover = vi.fn(); + const { rerender, result } = renderHook( + ({ handlers, message }) => useMentionsHandler(message, handlers), + { + initialProps: { + handlers: { onMentionsClick: firstClick, onMentionsHover: onMentionsHoverMock }, + message, + }, + }, ); + const before = result.current; + + rerender({ + handlers: { onMentionsClick: nextClick, onMentionsHover: nextHover }, + message: nextMessage, + }); + before.onMentionsClick?.(event); + before.onMentionsHover?.(event); + + expect(result.current.onMentionsClick).toBe(before.onMentionsClick); + expect(result.current.onMentionsHover).toBe(before.onMentionsHover); + expect(firstClick).not.toHaveBeenCalled(); + expect(nextClick).toHaveBeenCalledWith(event, nextMessage); + expect(nextHover).toHaveBeenCalledWith(event, nextMessage); }); }); diff --git a/src/components/Message/hooks/__tests__/useUserRole.test.tsx b/src/components/Message/hooks/__tests__/useMessagePermissions.test.tsx similarity index 60% rename from src/components/Message/hooks/__tests__/useUserRole.test.tsx rename to src/components/Message/hooks/__tests__/useMessagePermissions.test.tsx index 7456d21c51..115c21b96e 100644 --- a/src/components/Message/hooks/__tests__/useUserRole.test.tsx +++ b/src/components/Message/hooks/__tests__/useMessagePermissions.test.tsx @@ -3,7 +3,7 @@ import { renderHook } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { Channel, ChannelState, LocalMessage, StreamChat } from 'stream-chat'; -import { useUserRole } from '../useUserRole'; +import { useMessagePermissions } from '../useMessagePermissions'; import { ChannelInstanceProvider, ChatProvider } from '../../../../context'; import { @@ -23,22 +23,20 @@ const toOwnCapabilities = (capabilities: Record = {}) => .filter(([, enabled]) => enabled) .map(([capability]) => capability); -async function renderUserRoleHook({ +async function renderMessagePermissionsHook({ channelProps = {}, channelStateContextValue = {}, clientContextValue = {}, - disableQuotedMessages = undefined as boolean | undefined, message = generateMessage(), }: { channelProps?: { state?: { membership?: Partial } }; channelStateContextValue?: { channelCapabilities?: Record }; clientContextValue?: { client?: StreamChat }; - disableQuotedMessages?: boolean; message?: LocalMessage; } = {}) { const client = clientContextValue.client ?? (await getTestClientWithUser(alice)); const channel = fromPartial({ - cid: 'messaging:role-test', + cid: 'messaging:permissions-test', state: generateChannelState({ membership: fromPartial( channelProps.state?.membership ?? {}, @@ -53,13 +51,11 @@ async function renderUserRoleHook({ ); - const { result } = renderHook(() => useUserRole(message, disableQuotedMessages), { - wrapper, - }); + const { result } = renderHook(() => useMessagePermissions(message), { wrapper }); return result.current; } -describe('useUserRole custom hook', () => { +describe('useMessagePermissions', () => { afterEach(vi.clearAllMocks); it.each([ @@ -67,143 +63,31 @@ describe('useUserRole custom hook', () => { ['does not belong', bob, false], ])('should tell when the message %s to user', async (_, user, expected) => { const message = generateMessage({ user }); - const { isMyMessage } = await renderUserRoleHook({ message }); + const { isMyMessage } = await renderMessagePermissionsHook({ message }); expect(isMyMessage).toBe(expected); }); - it.each([ - ['admin', true], - ['member', false], - ['moderator', false], - ['channel_moderator', false], - ['owner', false], - ])('should tell if user is admin when user has %s role', async (role, expected) => { - const adminUser = generateUser({ role }); - const clientMock = await getTestClientWithUser(adminUser); - const { isAdmin } = await renderUserRoleHook({ - clientContextValue: { client: clientMock }, + it('does not return membership-role flags', async () => { + const result = await renderMessagePermissionsHook({ + channelProps: { state: { membership: { is_moderator: true, role: 'owner' } } }, }); - expect(isAdmin).toBe(expected); + expect(result).not.toHaveProperty('isAdmin'); + expect(result).not.toHaveProperty('isModerator'); + expect(result).not.toHaveProperty('isOwner'); }); - it.each([ - ['admin', true], - ['member', false], - ['moderator', false], - ['channel_moderator', false], - ['owner', false], - ])( - 'should tell if user is admin when channel state membership is set to %s', - async (role, expected) => { - const { isAdmin } = await renderUserRoleHook({ - channelProps: { - state: { - membership: { - role, - }, - }, - }, - }); - expect(isAdmin).toBe(expected); - }, - ); - - it.each([ - ['admin', false], - ['member', false], - ['moderator', false], - ['channel_moderator', false], - ['owner', true], - ])( - 'should tell if user is owner when channel state membership is set to %s', - async (role, expected) => { - const { isOwner } = await renderUserRoleHook({ - channelProps: { - state: { - membership: { - role, - }, - }, - }, - }); - - expect(isOwner).toBe(expected); - }, - ); - - it.each([ - ['admin', false], - ['member', false], - ['moderator', true], - ['channel_moderator', true], - ['owner', false], - ])( - 'should tell if user is moderator when channel state membership role is set to %s', - async (role, expected) => { - const { isModerator } = await renderUserRoleHook({ - channelProps: { - state: { - membership: { - role, - }, - }, - }, - }); - expect(isModerator).toBe(expected); - }, - ); - - it.each([ - [true, true], - [false, false], - [undefined, false], - ])( - 'should tell if user is moderator when channel state membership is_moderator is set to %s', - async (bool, expected) => { - const { isModerator } = await renderUserRoleHook({ - channelProps: { - state: { - membership: { - is_moderator: bool, - }, - }, - }, - }); - expect(isModerator).toBe(expected); - }, - ); - - it.each([ - ['admin', false], - ['member', false], - ['channel_moderator', true], - ['owner', false], - ])( - 'should tell if user is moderator when channel state membership channel_role is set to %s', - async (role, expected) => { - const { isModerator } = await renderUserRoleHook({ - channelProps: { - state: { - membership: { - channel_role: role, - }, - }, - }, - }); - expect(isModerator).toBe(expected); - }, - ); - it.each([ ['member', false], + ['member', true], ['admin', true], + ['admin', false], ['moderator', true], ['channel_moderator', true], ['owner', false], ])( - 'should allow user to edit or delete message if user role is %s', + 'takes canEdit and canDelete from the any-message capabilities, not the %s role', async (role, expected) => { - const { canDelete, canEdit } = await renderUserRoleHook({ + const { canDelete, canEdit } = await renderMessagePermissionsHook({ channelProps: { state: { membership: { @@ -228,7 +112,7 @@ describe('useUserRole custom hook', () => { ...generateMessage({ poll_id: 'poll-1', user: alice }), poll: fromPartial({ id: 'poll-1' }), } as LocalMessage; - const { canEdit } = await renderUserRoleHook({ + const { canEdit } = await renderMessagePermissionsHook({ channelStateContextValue: { channelCapabilities: { 'update-own-message': true } }, message, }); @@ -249,7 +133,7 @@ describe('useUserRole custom hook', () => { 'determine message edit permission', async (updateAnyPermission, updateOwnPermission, messageAuthor, expected) => { const message = generateMessage({ user: messageAuthor }); - const { canEdit } = await renderUserRoleHook({ + const { canEdit } = await renderMessagePermissionsHook({ channelStateContextValue: { channelCapabilities: { 'update-any-message': updateAnyPermission, @@ -275,7 +159,7 @@ describe('useUserRole custom hook', () => { 'determine delete message permission', async (deleteAnyMessagePerm, deleteOwnMessagePerm, messageAuthor, expected) => { const message = generateMessage({ user: messageAuthor }); - const { canDelete } = await renderUserRoleHook({ + const { canDelete } = await renderMessagePermissionsHook({ channelStateContextValue: { channelCapabilities: { 'delete-any-message': deleteAnyMessagePerm, @@ -297,7 +181,7 @@ describe('useUserRole custom hook', () => { 'determine flag message permission', async (flagMessagePerm, messageAuthor, expected) => { const message = generateMessage({ user: messageAuthor }); - const { canFlag } = await renderUserRoleHook({ + const { canFlag } = await renderMessagePermissionsHook({ channelStateContextValue: { channelCapabilities: { 'flag-message': flagMessagePerm, @@ -318,7 +202,7 @@ describe('useUserRole custom hook', () => { 'determine mute channel permission', async (muteChannelPerm, messageAuthor, expected) => { const message = generateMessage({ user: messageAuthor }); - const { canMute } = await renderUserRoleHook({ + const { canMute } = await renderMessagePermissionsHook({ channelStateContextValue: { channelCapabilities: { 'mute-channel': muteChannelPerm, @@ -339,7 +223,7 @@ describe('useUserRole custom hook', () => { 'determine mark unread permission', async (readEventsPerm, messageAuthor, expected) => { const message = generateMessage({ user: messageAuthor }); - const { canMarkUnread } = await renderUserRoleHook({ + const { canMarkUnread } = await renderMessagePermissionsHook({ channelStateContextValue: { channelCapabilities: { 'read-events': readEventsPerm, @@ -352,30 +236,24 @@ describe('useUserRole custom hook', () => { ); it.each([ - [true, true, false], - [true, false, false], - [false, true, true], - [false, false, false], - ])( - 'determine quote message permission', - async (disableQuotedMessages, quoteMessagePerm, expected) => { - const { canQuote } = await renderUserRoleHook({ - channelStateContextValue: { - channelCapabilities: { - 'quote-message': quoteMessagePerm, - }, + [true, true], + [false, false], + ])('determine quote message permission', async (quoteMessagePerm, expected) => { + const { canQuote } = await renderMessagePermissionsHook({ + channelStateContextValue: { + channelCapabilities: { + 'quote-message': quoteMessagePerm, }, - disableQuotedMessages, - }); - expect(canQuote).toBe(expected); - }, - ); + }, + }); + expect(canQuote).toBe(expected); + }); it.each([ [true, true], [false, false], ])('determine react to a message permission', async (sendReactionPerm, expected) => { - const { canReact } = await renderUserRoleHook({ + const { canReact } = await renderMessagePermissionsHook({ channelStateContextValue: { channelCapabilities: { 'send-reaction': sendReactionPerm, @@ -388,8 +266,8 @@ describe('useUserRole custom hook', () => { it.each([ [true, true], [false, false], - ])('determine react to a message permission', async (sendReplyPerm, expected) => { - const { canReply } = await renderUserRoleHook({ + ])('determine reply to a message permission', async (sendReplyPerm, expected) => { + const { canReply } = await renderMessagePermissionsHook({ channelStateContextValue: { channelCapabilities: { 'send-reply': sendReplyPerm, diff --git a/src/components/Message/hooks/index.ts b/src/components/Message/hooks/index.ts index 09445a6dae..1029b03f01 100644 --- a/src/components/Message/hooks/index.ts +++ b/src/components/Message/hooks/index.ts @@ -10,7 +10,7 @@ export * from './usePinHandler'; export * from './useReactionHandler'; export * from './useRetryHandler'; export * from './useUserHandler'; -export * from './useUserRole'; +export * from './useMessagePermissions'; export * from './useReactionsFetcher'; export * from './useMessageTextStreaming'; export * from './useMessageReminder'; diff --git a/src/components/Message/hooks/useMentionsHandler.ts b/src/components/Message/hooks/useMentionsHandler.ts index fea154b3a0..b9dd6ae4da 100644 --- a/src/components/Message/hooks/useMentionsHandler.ts +++ b/src/components/Message/hooks/useMentionsHandler.ts @@ -1,55 +1,49 @@ import type React from 'react'; -import type { LocalMessage, UserResponse } from 'stream-chat'; +import type { LocalMessage } from 'stream-chat'; +import { messageHasMentions } from '../renderText/rehypePlugins'; +import { useStableCallback } from '../../../utils'; import type { ReactEventHandler } from '../types'; export type CustomMentionHandler = ( event: React.BaseSyntheticEvent, - /** - * @deprecated Use the third `message` argument to access mention metadata instead. - * FIXME: Remove this argument in the next major release. - */ - mentioned_users: UserResponse[], message: LocalMessage, ) => void; -export type MentionedUserEventHandler = ( +/** Calls `handler` with the message, only for a message that mentions anyone. */ +const callForMentions = ( + handler: CustomMentionHandler | undefined, event: React.BaseSyntheticEvent, - mentionedUsers: UserResponse[], -) => void; - -function createEventHandler( - fn?: CustomMentionHandler, message?: LocalMessage, -): ReactEventHandler { - return (event) => { - const hasMentions = Boolean( - message?.mentioned_users?.length || - message?.mentioned_channel || - message?.mentioned_here || - message?.mentioned_roles?.length || - message?.mentioned_groups?.length, - ); - - if (typeof fn !== 'function' || !message || !hasMentions) { - return; - } - fn(event, message?.mentioned_users ?? [], message); - }; -} +) => { + if (!handler || !message || !messageHasMentions(message)) return; + handler(event, message); +}; +/** + * Returns the event handlers for a message's mentions, each `undefined` when its custom handler is + * not set. A returned handler keeps its identity across renders and always calls the latest custom + * handler and message, so a memoized message that keeps an earlier one still runs the current. + */ export const useMentionsHandler = ( message?: LocalMessage, customMentionHandler?: { onMentionsClick?: CustomMentionHandler; onMentionsHover?: CustomMentionHandler; }, -) => { - const onMentionsClick = customMentionHandler?.onMentionsClick || (() => null); - const onMentionsHover = customMentionHandler?.onMentionsHover || (() => null); +): { + onMentionsClick?: ReactEventHandler; + onMentionsHover?: ReactEventHandler; +} => { + const onMentionsClick = useStableCallback((event: React.BaseSyntheticEvent) => + callForMentions(customMentionHandler?.onMentionsClick, event, message), + ); + const onMentionsHover = useStableCallback((event: React.BaseSyntheticEvent) => + callForMentions(customMentionHandler?.onMentionsHover, event, message), + ); return { - onMentionsClick: createEventHandler(onMentionsClick, message), - onMentionsHover: createEventHandler(onMentionsHover, message), + onMentionsClick: customMentionHandler?.onMentionsClick ? onMentionsClick : undefined, + onMentionsHover: customMentionHandler?.onMentionsHover ? onMentionsHover : undefined, }; }; diff --git a/src/components/Message/hooks/useUserRole.ts b/src/components/Message/hooks/useMessagePermissions.ts similarity index 61% rename from src/components/Message/hooks/useUserRole.ts rename to src/components/Message/hooks/useMessagePermissions.ts index 19b28fcb6d..5c3f5b970b 100644 --- a/src/components/Message/hooks/useUserRole.ts +++ b/src/components/Message/hooks/useMessagePermissions.ts @@ -2,26 +2,11 @@ import { useChannel, useChatContext } from '../../../context'; import { useChannelCapabilities } from '../../Channel/hooks/useChannelCapabilities'; import type { LocalMessage } from 'stream-chat'; -export const useUserRole = (message: LocalMessage, disableQuotedMessages?: boolean) => { +export const useMessagePermissions = (message: LocalMessage) => { const channel = useChannel(); const { client } = useChatContext(); const channelCapabilities = useChannelCapabilities({ cid: channel.cid }); - /** @deprecated Relies on the deprecated `membership.role`. Use `channelCapabilities` instead. */ - const isAdmin = - client.user?.role === 'admin' || channel.state.membership.role === 'admin'; - - /** @deprecated Relies on the deprecated `membership.role`. Use `channelCapabilities` instead. */ - const isOwner = channel.state.membership.role === 'owner'; - - /** @deprecated Relies on the deprecated `membership.role`. Use `channelCapabilities` instead. */ - const isModerator = - client.user?.role === 'channel_moderator' || - channel.state.membership.role === 'channel_moderator' || - channel.state.membership.role === 'moderator' || - channel.state.membership.is_moderator === true || - channel.state.membership.channel_role === 'channel_moderator'; - const isMyMessage = client.userID === message.user?.id; // Permission only. Which messages have editable content (no poll, no giphy) is the `edit` @@ -38,7 +23,7 @@ export const useUserRole = (message: LocalMessage, disableQuotedMessages?: boole const canMute = !isMyMessage && channelCapabilities.has('mute-channel'); const canBlockUser = !isMyMessage; const canMarkUnread = !isMyMessage && channelCapabilities.has('read-events'); - const canQuote = !disableQuotedMessages && channelCapabilities.has('quote-message'); + const canQuote = channelCapabilities.has('quote-message'); const canReact = channelCapabilities.has('send-reaction'); const canReply = channelCapabilities.has('send-reply'); const canSendMessage = channelCapabilities.has('send-message'); @@ -55,9 +40,6 @@ export const useUserRole = (message: LocalMessage, disableQuotedMessages?: boole canReact, canReply, canSendMessage, - isAdmin, - isModerator, isMyMessage, - isOwner, }; }; diff --git a/src/components/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts index 827e8ca1a4..da23dbd6c7 100644 --- a/src/components/Message/hooks/usePinHandler.ts +++ b/src/components/Message/hooks/usePinHandler.ts @@ -8,33 +8,6 @@ import type { LocalMessage, UserResponse } from 'stream-chat'; import type { ReactEventHandler } from '../types'; import { nowNs } from 'stream-chat'; -// @deprecated in favor of `channelCapabilities` - TODO: remove in next major release -export type PinEnabledUserRoles = Partial< - Record -> & { - admin?: boolean; - anonymous?: boolean; - channel_member?: boolean; - channel_moderator?: boolean; - guest?: boolean; - member?: boolean; - moderator?: boolean; - owner?: boolean; - user?: boolean; -}; - -// @deprecated in favor of `channelCapabilities` - TODO: remove in next major release -export type PinPermissions< - T extends string = string, - U extends string = string, -> = Partial>> & { - commerce?: PinEnabledUserRoles; - gaming?: PinEnabledUserRoles; - livestream?: PinEnabledUserRoles; - messaging?: PinEnabledUserRoles; - team?: PinEnabledUserRoles; -}; - export type PinMessageNotifications = { getErrorNotification?: (message: LocalMessage) => string; notify?: (notificationText: string, type: 'success' | 'error') => void; diff --git a/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.tsx.snap b/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.tsx.snap index 676771b9d9..e201dec0aa 100644 --- a/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.tsx.snap +++ b/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.tsx.snap @@ -780,7 +780,6 @@ exports[`renderText > renders custom mention 1`] = ` @username@email.com @@ -788,7 +787,6 @@ exports[`renderText > renders custom mention 1`] = ` @username@email.com @@ -805,7 +803,6 @@ exports[`renderText > renders custom mention 1`] = ` @username@email.com diff --git a/src/components/Message/renderText/__tests__/renderText.test.tsx b/src/components/Message/renderText/__tests__/renderText.test.tsx index 1154387b1a..236905b791 100644 --- a/src/components/Message/renderText/__tests__/renderText.test.tsx +++ b/src/components/Message/renderText/__tests__/renderText.test.tsx @@ -2,7 +2,11 @@ import React from 'react'; import { findAndReplace } from 'hast-util-find-and-replace'; import { u } from 'unist-builder'; import { render, screen } from '@testing-library/react'; -import { mentionsMarkdownPlugin } from '../rehypePlugins'; +import { + getRenderTextMentionEntities, + mentionsMarkdownPlugin, + messageHasMentions, +} from '../rehypePlugins'; import { htmlToTextPlugin, imageToLink, @@ -11,9 +15,16 @@ import { remarkIgnoreMarkdown, } from '../remarkPlugins'; import { defaultAllowedTagNames, renderText } from '../renderText'; +import { fromPartial } from '@total-typescript/shoehorn'; +import type { UserGroupResponse } from 'stream-chat'; +import { generateUser } from '../../../../mock-builders'; const strikeThroughText = '~~xxx~~'; +const userMentions = (users) => ({ + messageMentionEntities: getRenderTextMentionEntities({ mentioned_users: users }), +}); + describe(`renderText`, () => { it('wraps every link entirely even though the link roots are the same', () => { const Markdown = renderText('copilot.com\ncopilot.com/guide\ncopilot.com/about?'); @@ -39,7 +50,7 @@ describe(`renderText`, () => { it('handles the special case where user name matches to an e-mail pattern - 1', () => { const Markdown = renderText( 'Hello @username@email.com, is username@email.com your @primary e-mail?', - [{ id: 'id-username@email.com', name: 'username@email.com' }], + userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); @@ -48,7 +59,7 @@ describe(`renderText`, () => { it('handles the special case where user name matches to an e-mail pattern - 2', () => { const Markdown = renderText( 'username@email.com @username@email.com is this the right address?', - [{ id: 'id-username@email.com', name: 'username@email.com' }], + userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); @@ -57,7 +68,7 @@ describe(`renderText`, () => { it('handles the special case where user name matches to an e-mail pattern - 3', () => { const Markdown = renderText( '@username@email.com @username@email.com @username@email.com @username@email.com', - [{ id: 'id-username@email.com', name: 'username@email.com' }], + userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); @@ -66,14 +77,14 @@ describe(`renderText`, () => { it('handles the special case where user name matches to an e-mail pattern - 4', () => { const Markdown = renderText( '@username@email.com @username@email.com username@email.com @username@email.com', - [{ id: 'id-username@email.com', name: 'username@email.com' }], + userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); }); it('handles the special case where role name matches an e-mail pattern', () => { - const Markdown = renderText('Hello @support@example.com', undefined, { + const Markdown = renderText('Hello @support@example.com', { messageMentionEntities: [ { id: 'support@example.com', @@ -100,7 +111,7 @@ describe(`renderText`, () => { }); it('handles the special case where user-group name matches an e-mail pattern', () => { - const Markdown = renderText('Hello @backend@stream.io', undefined, { + const Markdown = renderText('Hello @backend@stream.io', { messageMentionEntities: [ { id: 'backend-team', @@ -127,7 +138,7 @@ describe(`renderText`, () => { }); it('wraps user-group mentions by id even when the entity also has a name', () => { - const Markdown = renderText('Hello @backend-team', undefined, { + const Markdown = renderText('Hello @backend-team', { messageMentionEntities: [ { id: 'backend-team', @@ -154,7 +165,7 @@ describe(`renderText`, () => { }); it('wraps multi-word mention names without consuming following text', () => { - const Markdown = renderText('Hello @React support hey', undefined, { + const Markdown = renderText('Hello @React support hey', { messageMentionEntities: [ { id: 'react-support', @@ -182,7 +193,7 @@ describe(`renderText`, () => { }); it('does not match a multi-word mention name as a prefix of a longer token', () => { - const Markdown = renderText('Hello @React supportive hey', undefined, { + const Markdown = renderText('Hello @React supportive hey', { messageMentionEntities: [ { id: 'react-support', @@ -202,19 +213,22 @@ describe(`renderText`, () => { }); it('renders custom mention', () => { - const CustomMention = (props) => ( - - {props.children} - - ); + const mentionNodes = []; + const CustomMention = (props) => { + mentionNodes.push(props.node); + return ( + + {props.children} + + ); + }; const Markdown = renderText( '@username@email.com @username@email.com username@email.com @username@email.com', - [{ id: 'id-username@email.com', name: 'username@email.com' }], { + ...userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), customMarkDownRenderers: { mention: CustomMention, }, @@ -222,12 +236,24 @@ describe(`renderText`, () => { ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); + expect(mentionNodes).not.toHaveLength(0); + mentionNodes.forEach((node) => expect(node).not.toHaveProperty('mentionedUser')); + }); + + it('renders mentions as plain text without messageMentionEntities', () => { + const { container } = render(renderText('Hello @alice')); + expect(container.querySelector('.str-chat__message-mention')).toBeNull(); + expect(container).toHaveTextContent('Hello @alice'); }); - it('keeps the exported mentionsMarkdownPlugin backward compatible for UserResponse arrays', () => { - const Markdown = renderText('Hello @alice', undefined, { + it('highlights the mention entities passed to the exported mentionsMarkdownPlugin', () => { + const Markdown = renderText('Hello @alice', { getRehypePlugins: (defaultPlugins) => [ - mentionsMarkdownPlugin([{ id: 'alice-id', name: 'alice' }]), + mentionsMarkdownPlugin( + getRenderTextMentionEntities({ + mentioned_users: [generateUser({ id: 'alice-id', name: 'alice' })], + }), + ), ...defaultPlugins, ], }); @@ -278,7 +304,7 @@ describe(`renderText`, () => { ])( 'renders a mention tag for %s mentions regardless of channel capabilities', (_, text, mentionEntity) => { - const Markdown = renderText(text, undefined, { + const Markdown = renderText(text, { messageMentionEntities: [mentionEntity], }); const { container } = render(Markdown); @@ -293,12 +319,11 @@ describe(`renderText`, () => { {props.children} ); - const Markdown = renderText('Hello @channel and @admin', undefined, { + const Markdown = renderText('Hello @channel and @admin', { customMarkDownRenderers: { mention: CustomMention, }, @@ -331,7 +356,7 @@ describe(`renderText`, () => { }); it('renders standard markdown text', () => { - const Markdown = renderText('Hi, shall we meet on **Tuesday**?', []); + const Markdown = renderText('Hi, shall we meet on **Tuesday**?'); const { container } = render(Markdown); expect(container).toMatchSnapshot(); }); @@ -339,48 +364,52 @@ describe(`renderText`, () => { it('renders remark-gfm list and strikethrough correctly', () => { const Markdown = renderText( 'Pick a time to meet:\n- Wednesday\n- Thursday\n- ~~Sunday~~\n- ~Monday~\n', - [], ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); }); it("handles the special case where there's at least one mention and @ symbol at the end", () => { - const Markdown = renderText('@username@email.com @', [ - { id: 'id-username@email.com', name: 'username@email.com' }, - ]); + const Markdown = renderText( + '@username@email.com @', + userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), + ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); }); it('handles the special case where there are pronouns in the name', () => { - const Markdown = renderText('hey, @John (they/them), how are you?', [ - { id: 'john', name: 'John (they/them)' }, - ]); + const Markdown = renderText( + 'hey, @John (they/them), how are you?', + userMentions([{ id: 'john', name: 'John (they/them)' }]), + ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); }); it('handles the special case where there is a forward slash in the name', () => { - const Markdown = renderText('hey, @John/Cena, how are you?', [ - { id: 'john', name: 'John/Cena' }, - ]); + const Markdown = renderText( + 'hey, @John/Cena, how are you?', + userMentions([{ id: 'john', name: 'John/Cena' }]), + ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); }); it('handles the special case where there is a backslash in the name', () => { - const Markdown = renderText('hey, @John\\Cena, how are you?', [ - { id: 'john', name: 'John\\Cena' }, - ]); + const Markdown = renderText( + 'hey, @John\\Cena, how are you?', + userMentions([{ id: 'john', name: 'John\\Cena' }]), + ); const { container } = render(Markdown); expect(container).toMatchSnapshot(); }); it('parses user mention to default format', () => { - const Markdown = renderText('@username@email.com', [ - { id: 'id-username@email.com', name: 'username@email.com' }, - ]); + const Markdown = renderText( + '@username@email.com', + userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), + ); const { container } = render(Markdown); expect(container).toMatchInlineSnapshot(`
@@ -401,11 +430,10 @@ describe(`renderText`, () => { it('allows to override rehype plugins', () => { const customPlugin = () => (tree) => tree; const getRehypePlugins = () => [customPlugin]; - const Markdown = renderText( - '@username@email.com', - [{ id: 'id-username@email.com', name: 'username@email.com' }], - { getRehypePlugins }, - ); + const Markdown = renderText('@username@email.com', { + ...userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), + getRehypePlugins, + }); const { container } = render(Markdown); expect(container).toMatchInlineSnapshot(`
@@ -428,11 +456,10 @@ describe(`renderText`, () => { const customPlugin = () => (tree) => findAndReplace(tree, [/.*@.*/, () => u('text', '#')]); const getRehypePlugins = (defaultPlugins) => [customPlugin, ...defaultPlugins]; - const Markdown = renderText( - '@username@email.com', - [{ id: 'id-username@email.com', name: 'username@email.com' }], - { getRehypePlugins }, - ); + const Markdown = renderText('@username@email.com', { + ...userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), + getRehypePlugins, + }); const { container } = render(Markdown); expect(container).toMatchInlineSnapshot(`
@@ -455,11 +482,10 @@ describe(`renderText`, () => { const customPlugin = () => (tree) => findAndReplace(tree, [/.*@.*/, () => u('text', '#')]); const getRehypePlugins = (defaultPlugins) => [...defaultPlugins, customPlugin]; - const Markdown = renderText( - '@username@email.com', - [{ id: 'id-username@email.com', name: 'username@email.com' }], - { getRehypePlugins }, - ); + const Markdown = renderText('@username@email.com', { + ...userMentions([{ id: 'id-username@email.com', name: 'username@email.com' }]), + getRehypePlugins, + }); const { container } = render(Markdown); expect(container).toMatchInlineSnapshot(`
@@ -494,7 +520,7 @@ describe(`renderText`, () => { it('allows to override remark plugins', () => { const customPlugin = () => (tree) => tree; const getRemarkPlugins = () => [customPlugin]; - const Markdown = renderText(strikeThroughText, [], { getRemarkPlugins }); + const Markdown = renderText(strikeThroughText, { getRemarkPlugins }); const { container } = render(Markdown); expect(container).toMatchInlineSnapshot(`
@@ -520,7 +546,7 @@ describe(`renderText`, () => { ]; [getRemarkPluginsFirstCustom, getRemarkPluginsFirstDefault].forEach( (getRemarkPlugins) => { - const Markdown = renderText(strikeThroughText, [], { + const Markdown = renderText(strikeThroughText, { getRemarkPlugins, }); const { container } = render(Markdown); @@ -536,7 +562,7 @@ describe(`renderText`, () => { u('element', { properties: {}, tagName: customTagName }, [u('text', match)]); const customPlugin = () => (tree) => findAndReplace(tree, [/b/, replace]); const getRehypePlugins = (defaultPlugins) => [customPlugin, ...defaultPlugins]; - const Markdown = renderText(text, [], { + const Markdown = renderText(text, { allowedTagNames: [...defaultAllowedTagNames, customTagName], getRehypePlugins, }); @@ -567,9 +593,34 @@ const withStrikeThroughText = `a${lineBreaks}${strikeThroughText}${lineBreaks}b` const tableText = `a${lineBreaks}| a | b | c | d |\n| - | :- | -: | :-: |\n| a | b | c | d |${lineBreaks}c`; const multilineWithStrongText = 'This is **the first** line\n\nThis is the second line'; +describe('messageHasMentions', () => { + it.each([ + ['a user', { mentioned_users: [generateUser()] }], + ['@channel', { mentioned_channel: true }], + ['@here', { mentioned_here: true }], + ['a role', { mentioned_roles: ['admin'] }], + ['a user group', { mentioned_groups: [fromPartial({ id: 'g' })] }], + ])('is true for a message that mentions %s', (_, metadata) => { + expect(messageHasMentions(metadata)).toBe(true); + }); + + it('is false for a message without mentions', () => { + expect(messageHasMentions(undefined)).toBe(false); + expect( + messageHasMentions({ + mentioned_channel: false, + mentioned_groups: [], + mentioned_here: false, + mentioned_roles: [], + mentioned_users: [], + }), + ).toBe(false); + }); +}); + describe('keepLineBreaksPlugin', () => { const doRenderText = (text, present) => { - const Markdown = renderText(text, [], { + const Markdown = renderText(text, { getRemarkPlugins: () => (present ? [keepLineBreaksPlugin] : []), }); return render(Markdown).container; @@ -705,7 +756,7 @@ describe('htmlToTextPlugin', () => {
`; - const Markdown = renderText(textWithHtml, [], { + const Markdown = renderText(textWithHtml, { getRemarkPlugins: () => (withPlugin ? [htmlToTextPlugin] : []), }); return render(Markdown).container; @@ -724,7 +775,7 @@ describe('htmlToTextPlugin', () => { describe('plusPlusToEmphasis', () => { const renderTextPlusPlus = (text, withPlugin = true) => { - const Markdown = renderText(text, [], { + const Markdown = renderText(text, { getRemarkPlugins: () => (withPlugin ? [plusPlusToEmphasis] : []), }); return render(Markdown).container; @@ -754,7 +805,7 @@ describe('plusPlusToEmphasis', () => { describe('imageToLink', () => { const renderImageToLink = (text, withPlugin = true) => { - const Markdown = renderText(text, [], { + const Markdown = renderText(text, { getRemarkPlugins: () => (withPlugin ? [imageToLink] : []), }); return render(Markdown).container; @@ -795,7 +846,7 @@ describe('remarkIgnoreMarkdown', () => { ].join('\n'); const renderWithPlugin = (plugins = []) => { - const Markdown = renderText(text, [], { + const Markdown = renderText(text, { getRemarkPlugins: () => [...plugins, remarkIgnoreMarkdown], }); return render(Markdown).container; diff --git a/src/components/Message/renderText/componentRenderers/Mention.tsx b/src/components/Message/renderText/componentRenderers/Mention.tsx index b6caba485c..c4542096f5 100644 --- a/src/components/Message/renderText/componentRenderers/Mention.tsx +++ b/src/components/Message/renderText/componentRenderers/Mention.tsx @@ -1,27 +1,10 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; -import type { UserResponse } from 'stream-chat'; import type { RenderTextMentionEntity } from '../rehypePlugins/mentionsMarkdownPlugin'; -type UserMentionNode = { - mentionedEntity: Extract; - /** - * @deprecated Use `mentionedEntity` instead. - */ - mentionedUser: UserResponse; -}; - -type NonUserMentionNode = { - mentionedEntity: Exclude; - /** - * @deprecated Use `mentionedEntity` instead. - */ - mentionedUser?: undefined; -}; - export type MentionProps = PropsWithChildren<{ - node: UserMentionNode | NonUserMentionNode; + node: { mentionedEntity: RenderTextMentionEntity }; }>; export const Mention = ({ children, node: { mentionedEntity } }: MentionProps) => ( diff --git a/src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts b/src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts index 05185148eb..3e1c98bddf 100644 --- a/src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts +++ b/src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts @@ -71,11 +71,6 @@ type TextNode = { // punctuation, which stays outside the mention node. const MENTION_TRAILING_BOUNDARY = String.raw`(?=$|\s|[!,.?;:'")\]}])`; -const isUserMentionEntity = ( - entity: RenderTextMentionEntity, -): entity is Extract => - entity.mentionType === 'user'; - /** * Returns the display texts, without the leading `@`, that match the entity in message text. * Roles and user groups match by name or ID, other entities by name falling back to ID. @@ -146,22 +141,25 @@ export const getRenderTextMentionEntities = ( return mentions; }; -const toRenderTextMentionEntities = ( - mentionEntities: RenderTextMentionEntity[] | UserResponse[], -): RenderTextMentionEntity[] => - mentionEntities.map((entity) => - 'mentionType' in entity - ? entity - : { - ...entity, - mentionType: 'user', - }, +/** + * Whether the message mentions anyone: a user, `@channel`, `@here`, a role or a user group. It is + * true exactly when `getRenderTextMentionEntities` returns at least one entity, without building + * them. + * + * @param metadata - The message's mention fields. + */ +export const messageHasMentions = (metadata?: RenderTextMentionMetadata) => + Boolean( + metadata?.mentioned_users?.length || + metadata?.mentioned_channel || + metadata?.mentioned_here || + metadata?.mentioned_roles?.length || + metadata?.mentioned_groups?.length, ); const createMentionLookup = ( - mentionEntitiesOrUsers: RenderTextMentionEntity[] | UserResponse[], + mentionEntities: RenderTextMentionEntity[], ): MentionLookup => { - const mentionEntities = toRenderTextMentionEntities(mentionEntitiesOrUsers); const mentionReplacementMap = mentionEntities.reduce((replacementMap, entity) => { getMentionDisplayTexts(entity).forEach((mentionDisplayText) => { const prefixedMentionDisplayText = `@${mentionDisplayText}`; @@ -210,18 +208,11 @@ export const mentionsMarkdownPluginFromLookup = return u( 'element', - isUserMentionEntity(replacement.entity) - ? { - mentionedEntity: replacement.entity, - mentionedUser: replacement.entity, - properties: {}, - tagName: 'mention', - } - : { - mentionedEntity: replacement.entity, - properties: {}, - tagName: 'mention', - }, + { + mentionedEntity: replacement.entity, + properties: {}, + tagName: 'mention', + }, [u('text', mentionDisplayText)], ); }; @@ -267,9 +258,9 @@ export const mentionsMarkdownPluginFromLookup = }; export const createMentionPluginAndDisplayTextSet = ( - mentionEntitiesOrUsers: RenderTextMentionEntity[] | UserResponse[], + mentionEntities: RenderTextMentionEntity[], ): MentionPluginAndDisplayTextSet => { - const mentionLookup = createMentionLookup(mentionEntitiesOrUsers); + const mentionLookup = createMentionLookup(mentionEntities); return { mentionDisplayTextSet: mentionLookup.mentionDisplayTextSet, @@ -277,14 +268,8 @@ export const createMentionPluginAndDisplayTextSet = ( }; }; -export function mentionsMarkdownPlugin( - mentionedUsers: UserResponse[], -): () => (tree: HastTree) => void; export function mentionsMarkdownPlugin( mentionEntities: RenderTextMentionEntity[], -): () => (tree: HastTree) => void; -export function mentionsMarkdownPlugin( - mentionEntitiesOrUsers: RenderTextMentionEntity[] | UserResponse[], -) { - return createMentionPluginAndDisplayTextSet(mentionEntitiesOrUsers).plugin; +): () => (tree: HastTree) => void { + return createMentionPluginAndDisplayTextSet(mentionEntities).plugin; } diff --git a/src/components/Message/renderText/renderText.tsx b/src/components/Message/renderText/renderText.tsx index 372786af98..9d16320ece 100644 --- a/src/components/Message/renderText/renderText.tsx +++ b/src/components/Message/renderText/renderText.tsx @@ -4,7 +4,6 @@ import { find } from 'linkifyjs'; import remarkGfm from 'remark-gfm'; import type { ComponentType, JSX, ReactNode } from 'react'; import type { Options } from 'react-markdown'; -import type { UserResponse } from 'stream-chat'; import type { PluggableList } from 'unified'; import { Anchor, Emoji, Mention } from './componentRenderers'; @@ -12,7 +11,6 @@ import { detectHttp, matchMarkdownLinks, messageCodeBlocks } from './regex'; import { createMentionPluginAndDisplayTextSet, emojiMarkdownPlugin, - getRenderTextMentionEntities, } from './rehypePlugins'; import { htmlToTextPlugin, @@ -105,43 +103,32 @@ export type RenderTextOptions = { getRemarkPlugins?: RenderTextPluginConfigurator; /** * Mention entities to highlight in the rendered text, including `@channel`, `@here`, roles and - * user groups. Takes precedence over the deprecated `mentionedUsers` argument. + * user groups. */ messageMentionEntities?: RenderTextMentionEntity[]; }; export type RenderTextFunction = ( text?: string, - /** - * @deprecated Use `options.messageMentionEntities` instead, which also covers non-user mentions. - */ - mentionedUsers?: UserResponse[], options?: RenderTextOptions, ) => ReactNode; export function renderText( text?: string, - /** - * @deprecated Use `options.messageMentionEntities` instead, which also covers non-user mentions. - */ - mentionedUsers?: UserResponse[], { allowedTagNames = defaultAllowedTagNames, customMarkDownRenderers, getRehypePlugins = getPluginsForward, getRemarkPlugins = getPluginsForward, - messageMentionEntities, + messageMentionEntities = [], }: RenderTextOptions = {}, ) { if (!text) return null; if (text.trim().length === 1) return <>{text}; let newText = text; - const renderTextMentionEntities = - messageMentionEntities ?? - getRenderTextMentionEntities({ mentioned_users: mentionedUsers }); const { mentionDisplayTextSet, plugin: mentionsPlugin } = - createMentionPluginAndDisplayTextSet(renderTextMentionEntities); + createMentionPluginAndDisplayTextSet(messageMentionEntities); const markdownLinks = matchMarkdownLinks(newText); const codeBlocks = messageCodeBlocks(newText); @@ -202,7 +189,7 @@ export function renderText( ]; const rehypePlugins: PluggableList = [emojiMarkdownPlugin]; - if (renderTextMentionEntities.length) { + if (messageMentionEntities.length) { rehypePlugins.push(mentionsPlugin); } diff --git a/src/components/Message/styling/Message.scss b/src/components/Message/styling/Message.scss index 40f3357b70..49f7195455 100644 --- a/src/components/Message/styling/Message.scss +++ b/src/components/Message/styling/Message.scss @@ -175,6 +175,11 @@ $message-bubble-padding: var(--str-chat__spacing-xs); color: var(--str-chat__accent-primary); } +// Set by `MessageText` when the message has mentions and an `onMentionsClick` handler is set. +.str-chat__message-text-inner--mentions-clickable .str-chat__message-mention { + cursor: pointer; +} + .str-chat__message { --str-chat-message-options-size: calc(3 * var(--str-chat__message-options-button-size)); diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index cc76530a87..667be71e88 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -28,8 +28,6 @@ export type MessageProps = { closeReactionSelectorOnClick?: boolean; /** Users that have confirmed the message delivery to their device. */ deliveredTo?: UserResponse[]; - /** If true, users cannot quote messages. Defaults to false. */ - disableQuotedMessages?: boolean; // todo: remove /** Overrides the date formatting. Receives the original date and returns a string. */ formatDate?: (date: Date) => string; diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 257c2a8144..cc2b7a89cc 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -84,13 +84,14 @@ function areMessagesEqual(prevMessage: LocalMessage, nextMessage: LocalMessage): ); } -export const areMessagePropsEqual = ( - prevProps: MessageProps & { - showDetailedReactions?: boolean; - }, - nextProps: MessageProps & { +type MemoizedMessageProps = MessageProps & + Pick & { showDetailedReactions?: boolean; - }, + }; + +export const areMessagePropsEqual = ( + prevProps: MemoizedMessageProps, + nextProps: MemoizedMessageProps, ) => { const { message: prevMessage } = prevProps; const { message: nextMessage } = nextProps; @@ -105,6 +106,17 @@ export const areMessagePropsEqual = ( return false; } + if (nextProps.showAvatar !== prevProps.showAvatar) return false; + + // Whether a mention handler is set decides if mentions are interactive, so a handler set or + // removed after the first render has to reach the message. Their identity is not compared. + if ( + !!nextProps.onMentionsClickMessage !== !!prevProps.onMentionsClickMessage || + !!nextProps.onMentionsHoverMessage !== !!prevProps.onMentionsHoverMessage + ) { + return false; + } + const messagesAreEqual = areMessagesEqual(prevMessage, nextMessage); if (!messagesAreEqual) return false; @@ -137,6 +149,7 @@ export const areMessageUIPropsEqual = ( if (prevProps.readBy?.length !== nextProps.readBy?.length) return false; if (prevProps.deliveredTo?.length !== nextProps.deliveredTo?.length) return false; if (prevProps.groupStyles !== nextProps.groupStyles) return false; + if (prevProps.showAvatar !== nextProps.showAvatar) return false; if (prevProps.showDetailedReactions !== nextProps.showDetailedReactions) { return false; diff --git a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts index 6cfda882f3..2d84966a5e 100644 --- a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts +++ b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useChannel, useMessageContext } from '../../../context'; -import { useUserRole } from '../../Message/hooks'; +import { useMessagePermissions } from '../../Message/hooks'; import { ACTIONS_NOT_WORKING_IN_THREAD, isMessageBounced, @@ -56,7 +56,7 @@ export const useBaseMessageActionSetFilter = ( canReply, canSendMessage, isMyMessage, - } = useUserRole(message); + } = useMessagePermissions(message); const isMessageThreadReply = typeof message.parent_id === 'string'; const isBounced = isMessageBounced(message); const allowRetry = isMessageErrorRetryable(message); diff --git a/src/components/MessageComposer/QuotedMessagePreview.tsx b/src/components/MessageComposer/QuotedMessagePreview.tsx index e0ae1a40ea..faf38841f3 100644 --- a/src/components/MessageComposer/QuotedMessagePreview.tsx +++ b/src/components/MessageComposer/QuotedMessagePreview.tsx @@ -434,7 +434,7 @@ export const QuotedMessagePreviewUI = ({ }); } } else if (renderText) { - renderedText = renderText(quotedMessageText, quotedMessage?.mentioned_users, { + renderedText = renderText(quotedMessageText, { messageMentionEntities: quotedMessageMentionEntities, }); } else { diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index fc11197629..6af915e6b7 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -1890,7 +1890,9 @@ describe(`MessageInputFlat`, () => { }); await initQuotedMessagePreview(m); - expect(fn).toHaveBeenCalled(); + expect(fn).toHaveBeenCalledWith(m.text, { + messageMentionEntities: expect.any(Array), + }); expect(await screen.findByTestId(m.text)).toBeInTheDocument(); }); diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 3931a6c4fe..c72fcf2bf3 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -29,7 +29,7 @@ import { TypingIndicator as DefaultTypingIndicator } from '../TypingIndicator'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; import { FloatingDateSeparator } from './FloatingDateSeparator'; -import type { MessageRenderer } from './renderMessages'; +import type { MessageRenderer, SharedMessageProps } from './renderMessages'; import { defaultRenderMessages } from './renderMessages'; import { useStableId } from '../UtilityComponents/useStableId'; import { useThreadContext } from '../Threads'; @@ -79,7 +79,6 @@ const getScrollBehavior = (): ScrollBehavior => const MessageListWithContext = (props: MessageListWithContextProps) => { const channel = useChannel(); - const threadHead = useThreadHead(); const { getGroupStyles, groupByUser = true, @@ -99,9 +98,24 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { showUnreadNotificationAlways, sortReactions, suppressAutoscroll: suppressAutoscrollFromProps = false, - unsafeHTML = false, withDateSeparator = true, } = props; + // The list-wide props every message gets, the thread's parent message included. + const messageProps: SharedMessageProps = { + additionalMessageComposerProps: props.additionalMessageComposerProps, + closeReactionSelectorOnClick: props.closeReactionSelectorOnClick, + formatDate: props.formatDate, + onMentionsClick: props.onMentionsClick, + onMentionsHover: props.onMentionsHover, + onUserClick: props.onUserClick, + onUserHover: props.onUserHover, + reactionDetailsSort, + renderText: props.renderText, + showAvatar: props.showAvatar, + sortReactions, + unsafeHTML: props.unsafeHTML, + }; + const threadHead = useThreadHead(messageProps); const thread = useThreadContext(); const isThreadList = !!thread; const [isLoadingOlder, setIsLoadingOlder] = React.useState(false); @@ -194,23 +208,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { const elements = useMessageListElements({ enrichedMessages, focusedMessageId, - internalMessageProps: { - additionalMessageComposerProps: props.additionalMessageComposerProps, - closeReactionSelectorOnClick: props.closeReactionSelectorOnClick, - disableQuotedMessages: props.disableQuotedMessages, - formatDate: props.formatDate, - messageListRect: wrapperRect, - onMentionsClick: props.onMentionsClick, - onMentionsHover: props.onMentionsHover, - onUserClick: props.onUserClick, - onUserHover: props.onUserHover, - reactionDetailsSort, - renderText: props.renderText, - // retrySendMessage: props.retrySendMessage, - showAvatar: props.showAvatar, - sortReactions, - unsafeHTML, - }, + internalMessageProps: { ...messageProps, messageListRect: wrapperRect }, lastOwnMessage, messageGroupStyles, messages, @@ -419,7 +417,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { type PropsDrilledToMessage = | 'additionalMessageComposerProps' | 'closeReactionSelectorOnClick' - | 'disableQuotedMessages' | 'formatDate' | 'onMentionsClick' | 'onMentionsHover' diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index de62cce1b5..5430f0d33d 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -2,13 +2,7 @@ import type { RefObject } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FloatingDateSeparator } from './FloatingDateSeparator'; -import type { - ComputeItemKey, - ScrollSeekConfiguration, - ScrollSeekPlaceholderProps, - VirtuosoHandle, - VirtuosoProps, -} from 'react-virtuoso'; +import type { ComputeItemKey, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; import { GiphyPreviewMessage as DefaultGiphyPreviewMessage } from './GiphyPreviewMessage'; @@ -210,20 +204,16 @@ const VirtualizedMessageListWithContext = ( channel, closeReactionSelectorOnClick, customMessageRenderer, - defaultItemHeight, formatDate, getGroupStyles, groupByUser = false, hideDeletedMessages = false, hideNewMessageSeparator = false, maxTimeBetweenGroupedMessages, - overscan = 0, - // TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component reactionDetailsSort, renderText, returnAllReadData = false, reviewProcessedMessage, - scrollSeekPlaceHolder, scrollToLatestMessageOnFocus = false, separateGiphyPreview = false, showAvatar, @@ -235,7 +225,20 @@ const VirtualizedMessageListWithContext = ( } = props; const thread = useThreadContext(); const isThreadList = !!thread; - const threadHead = useThreadHead(); + // The list-wide props every message gets, the thread's parent message included. + const messageProps: Pick< + VirtualizedMessageListProps, + PropsDrilledToMessage | 'closeReactionSelectorOnClick' + > = { + additionalMessageComposerProps, + closeReactionSelectorOnClick, + formatDate, + reactionDetailsSort, + renderText, + showAvatar, + sortReactions, + }; + const threadHead = useThreadHead(messageProps); const [suppressAutoscrollWhileLoadingOlder, setSuppressAutoscrollWhileLoadingOlder] = React.useState(false); const suppressAutoscroll = @@ -588,15 +591,13 @@ const VirtualizedMessageListWithContext = ( }} computeItemKey={computeItemKey} context={{ - additionalMessageComposerProps, + ...messageProps, channel, - closeReactionSelectorOnClick, customClasses, customMessageRenderer, DateSeparator, firstUnreadMessageId: channelUnreadUiState?.firstUnreadMessageId, focusedMessageId, - formatDate, groupByUser, head: threadHead, lastOwnMessage, @@ -610,11 +611,7 @@ const VirtualizedMessageListWithContext = ( ownMessagesDeliveredToOthers, ownMessagesReadByOthers, processedMessages, - reactionDetailsSort, - renderText, returnAllReadData, - showAvatar, - sortReactions, unreadMessageCount: channelUnreadUiState?.unreadCount, UnreadMessagesSeparator, virtuosoRef: virtuoso, @@ -630,15 +627,12 @@ const VirtualizedMessageListWithContext = ( itemSize={fractionalItemSize} itemsRendered={handleItemsRendered} key={messageSetKey} - overscan={overscan} ref={virtuoso} style={{ overflowX: 'hidden' }} totalCount={processedMessages.length} {...overridingVirtuosoProps} scrollerRef={scrollerRef} totalListHeightChanged={totalListHeightChanged} - {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} - {...(defaultItemHeight ? { defaultItemHeight } : {})} /> {/* Only for messages that arrived while the reader was scrolled up. Reading an older window (e.g. after a jump) is signalled by ScrollToLatestMessageButton instead. */} @@ -687,12 +681,6 @@ export type VirtualizedMessageListProps = Partial< messageList: RenderedMessage[], index: number, ) => React.ReactElement; - /** - * Item height used to compute the total list height; useful when message heights vary a lot. - * - * @deprecated Use additionalVirtuosoProps.defaultItemHeight instead. Will be removed with next major release - `v11.0.0`. - */ - defaultItemHeight?: number; /** Computes the group style of a message from its neighbors. Defaults to `getGroupStyles`. */ getGroupStyles?: GetGroupStyles; /** Groups consecutive messages from the same user. Defaults to `false`. */ @@ -703,12 +691,6 @@ export type VirtualizedMessageListProps = Partial< hideNewMessageSeparator?: boolean; /** Maximum time (ms) between two messages for them to stay in the same group. */ maxTimeBetweenGroupedMessages?: number; - /** - * Extra content rendered in addition to what fills the viewport. - * - * @deprecated Use additionalVirtuosoProps.overscan instead. Will be removed with next major release - `v11.0.0`. - */ - overscan?: number; /** Computes read and delivery data for every own message, not only the last one. */ returnAllReadData?: boolean; /** @@ -716,22 +698,6 @@ export type VirtualizedMessageListProps = Partial< * returned array is appended to the rendered messages in place of `changes`. */ reviewProcessedMessage?: ProcessMessagesParams['reviewProcessedMessage']; - /** - * Shows placeholders while the user scrolls fast through the list, e.g.: - * ``` - * { - * enter: (velocity) => Math.abs(velocity) > 120, - * exit: (velocity) => Math.abs(velocity) < 40, - * change: () => null, - * placeholder: ({index, height})=>
{index}
, - * } - * ``` - * - * @deprecated Pass additionalVirtuosoProps.scrollSeekConfiguration and specify the placeholder in additionalVirtuosoProps.components.ScrollSeekPlaceholder instead. Will be removed with next major release - `v11.0.0`. - */ - scrollSeekPlaceHolder?: ScrollSeekConfiguration & { - placeholder: React.ComponentType; - }; /** * Scrolls to the latest message when the window regains focus, if the messages changed while it * was blurred. Defaults to `false`. diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 6ccd3e3f01..d9c121cf10 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -47,6 +47,7 @@ import type { ComponentContextValue } from '../../../context'; import type { MockInstance } from 'vitest'; import type { ChannelProps } from '../../Channel'; import type { MessageListProps } from '../MessageList'; +import type { MessageProps } from '../../Message'; import { convertDateToTimestamp } from '../../../mock-builders'; import { ResizeObserverMock } from '../../../mock-builders/browser'; @@ -393,6 +394,41 @@ describe('MessageList', () => { }); }); + it('should give the thread parent message the message props of its replies', async () => { + const CustomThreadHead = vi.fn<(props: MessageProps) => React.ReactElement>(() => ( +
+ )); + const onMentionsClick = vi.fn(); + const onUserClick = vi.fn(); + const renderText = vi.fn(); + + await act(() => { + renderComponent({ + channelProps: { channel }, + chatClient, + components: { ThreadHead: CustomThreadHead }, + msgListProps: { + messages: [reply1, reply2], + onMentionsClick, + onUserClick, + renderText, + }, + thread: makeThreadStub(channel, message1), + }); + }); + + await waitFor(() => { + expect(CustomThreadHead.mock.lastCall?.[0]).toEqual( + expect.objectContaining({ + message: expect.objectContaining({ id: message1.id }), + onMentionsClick, + onUserClick, + renderText, + }), + ); + }); + }); + it('should render the thread parent message when no head is provided', async () => { // The list resolves the head from thread context and renders it inside its scroll container, // so `Thread` cannot place it as a sibling. diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index 2d3c4bb379..b76790ddb1 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -25,6 +25,7 @@ import { ThreadProvider } from '../../Threads'; import { StateStore } from '@stream-io/state-store'; import { fromPartial } from '@total-typescript/shoehorn'; import type { LocalMessage, Thread, ThreadState } from 'stream-chat'; +import type { MessageProps } from '../../Message'; const threadWithParentMessage = () => fromPartial({ @@ -222,6 +223,33 @@ describe('VirtualizedMessageList', () => { }); }); + it('gives the thread parent message the message props of its replies', async () => { + const CustomThreadHead = vi.fn<(props: MessageProps) => React.ReactElement>(() => ( +
+ )); + const renderText = vi.fn(); + const { channel, client } = await createChannel(); + + await act(() => { + render( + + + + + + + + + , + ); + }); + + const props = CustomThreadHead.mock.lastCall?.[0]; + expect(props).toEqual(expect.objectContaining({ renderText, showAvatar: false })); + // Props the list was not given stay unset, so an override's own defaults apply. + expect(props).not.toHaveProperty('formatDate'); + }); + // A bare index means align: 'start', which parks the last row's top at the viewport top and // clips its bottom whenever that row is taller than the list, visible in a short panel. jsdom has // no layout, so these assert the scroll target rather than the clipping. diff --git a/src/components/MessageList/__tests__/utils.test.ts b/src/components/MessageList/__tests__/utils.test.ts index 8f1390022b..bed9c332e3 100644 --- a/src/components/MessageList/__tests__/utils.test.ts +++ b/src/components/MessageList/__tests__/utils.test.ts @@ -892,7 +892,11 @@ describe('getMessageGroupStyles', () => { id: 'date-separator', }); const [first, second] = [1, 2].map((ms) => - generateMessage({ created_at: convertDateToTimestamp(new Date(ms)), user }), + generateMessage({ + created_at: convertDateToTimestamp(new Date(ms)), + id: `message-${ms}`, + user, + }), ); it('maps each message ID to its group style and leaves out items without one', () => { diff --git a/src/components/MessageList/hooks/__tests__/useThreadHead.test.tsx b/src/components/MessageList/hooks/__tests__/useThreadHead.test.tsx index c31aa789fe..7cfd91c2a6 100644 --- a/src/components/MessageList/hooks/__tests__/useThreadHead.test.tsx +++ b/src/components/MessageList/hooks/__tests__/useThreadHead.test.tsx @@ -7,6 +7,7 @@ import type { Thread, ThreadState } from 'stream-chat'; import { ComponentProvider } from '../../../../context'; import { ThreadProvider } from '../../../Threads'; import { useThreadHead } from '../useThreadHead'; +import type { ThreadHeadMessageProps } from '../useThreadHead'; import { generateMessage, mockComponentContext } from '../../../../mock-builders'; import type { ComponentContextValue } from '../../../../context'; @@ -21,12 +22,14 @@ const makeThread = () => const renderUseThreadHead = ({ componentOverrides = {}, + messageProps, thread, }: { componentOverrides?: Partial; + messageProps?: ThreadHeadMessageProps; thread?: Thread; } = {}) => - renderHook(() => useThreadHead(), { + renderHook(() => useThreadHead(messageProps), { wrapper: ({ children }) => ( {children} @@ -53,6 +56,39 @@ describe('useThreadHead', () => { ); }); + it('passes the list message props to the parent message', () => { + const onMentionsClick = vi.fn(); + const renderText = vi.fn(); + const { result } = renderUseThreadHead({ + messageProps: { onMentionsClick, renderText }, + thread: makeThread(), + }); + + expect(result.current?.props).toEqual( + expect.objectContaining({ message: parentMessage, onMentionsClick, renderText }), + ); + }); + + it('leaves out the list props that are undefined', () => { + const onMentionsClick = vi.fn(); + const { result } = renderUseThreadHead({ + messageProps: { onMentionsClick, onUserClick: undefined }, + thread: makeThread(), + }); + + expect(result.current?.props).toHaveProperty('onMentionsClick', onMentionsClick); + expect(result.current?.props).not.toHaveProperty('onUserClick'); + }); + + it('keeps the thread parent message when the list props carry a message', () => { + const { result } = renderUseThreadHead({ + messageProps: { message: generateMessage() } as ThreadHeadMessageProps, + thread: makeThread(), + }); + + expect(result.current?.props.message).toBe(parentMessage); + }); + it('honors a ThreadHead override from ComponentContext', () => { const CustomThreadHead = () =>
; const { result } = renderUseThreadHead({ diff --git a/src/components/MessageList/hooks/useThreadHead.tsx b/src/components/MessageList/hooks/useThreadHead.tsx index 95a43b2bb5..6d5b5f2e76 100644 --- a/src/components/MessageList/hooks/useThreadHead.tsx +++ b/src/components/MessageList/hooks/useThreadHead.tsx @@ -6,21 +6,41 @@ import { useThreadContext } from '../../Threads'; import { useStateStore } from '../../../store'; import type { ThreadState } from 'stream-chat'; +import type { MessageProps } from '../../Message/types'; const parentMessageSelector = ({ parentMessage }: ThreadState) => ({ parentMessage }); +/** The props a message list gives each of its messages, which the parent message gets too. */ +export type ThreadHeadMessageProps = Omit, 'message'>; + +/** + * Keeps only the props the list actually set. A `ThreadHead` override that sets a prop and then + * spreads its props (``) keeps its own value. + */ +const omitUndefinedProps = (messageProps: ThreadHeadMessageProps = {}) => + Object.fromEntries( + Object.entries(messageProps).filter(([, value]) => value !== undefined), + ) as ThreadHeadMessageProps; + /** * The parent message a thread list renders above its replies, or `null` outside a thread. * * The head belongs to the list, not to `Thread`, because it has to sit inside the scroll - * container. + * container. It takes the list-wide message props the replies get, so handlers such as + * `onMentionsClick` apply to the parent message as well. */ -export const useThreadHead = () => { +export const useThreadHead = (messageProps?: ThreadHeadMessageProps) => { const thread = useThreadContext(); const { ThreadHead = DefaultThreadHead } = useComponentContext(); const { parentMessage } = useStateStore(thread?.state, parentMessageSelector) ?? {}; if (!parentMessage) return null; - return ; + return ( + + ); }; diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index 1937a8a6bd..02aa5209a8 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -41,10 +41,10 @@ export type MessageContextValue = { handleRetry: (message: LocalMessage) => Promise | void; isMyMessage: () => boolean; message: LocalMessage; - /** Click handler for an @mention in the message. */ - onMentionsClickMessage: ReactEventHandler; - /** Hover handler for an @mention in the message. */ - onMentionsHoverMessage: ReactEventHandler; + /** Click handler for an @mention in the message; `undefined` when no `onMentionsClick` handler is set. */ + onMentionsClickMessage?: ReactEventHandler; + /** Hover handler for an @mention in the message; `undefined` when no `onMentionsHover` handler is set. */ + onMentionsHoverMessage?: ReactEventHandler; /** Click handler for the user who posted the message. */ onUserClick: ReactEventHandler; /** Hover handler for the user who posted the message. */