From d8659f7535a551d3e14283cbfc3d3e65f101d650 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Nov 2026 15:01:41 +0100 Subject: [PATCH 1/3] fix(VirtualizedMessageList): pass mention and user handlers and unsafeHTML to messages VirtualizedMessageList now takes onMentionsClick, onMentionsHover, onUserClick, onUserHover and unsafeHTML like MessageList, and passes them to every message, the thread's parent message and, for unsafeHTML, system messages. The unsafeHTML docs now warn that message.html is inserted unescaped. --- ai-docs/ai-migration-v14-v15.md | 98 +++++------ ai-docs/v15-breaking-changes.md | 8 +- .../EventComponent/EventComponent.tsx | 4 + src/components/Message/types.ts | 4 +- .../MessageList/VirtualizedMessageList.tsx | 17 +- .../VirtualizedMessageListComponents.tsx | 14 +- .../__tests__/VirtualizedMessageList.test.tsx | 40 ++++- .../VirtualizedMessageListComponents.test.tsx | 157 +++++++++++++++++- src/context/MessageContext.tsx | 4 +- 9 files changed, 284 insertions(+), 62 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 38937f17a..2fbf88a6a 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -207,7 +207,7 @@ Old → new for the most common changes. Ctrl-F the v14 name; the section has th | `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) | +| `Channel` `onMentionsClick` / `onMentionsHover` | The same props on `MessageList` / `VirtualizedMessageList` / `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) | @@ -271,51 +271,51 @@ Old → new for the most common changes. Ctrl-F the v14 name; the section has th These compile cleanly, or are ignored in plain JS, and change behavior at runtime. Check each one explicitly; the absence of an error does not mean the app is migrated. **Detect** lists `rg` patterns (run them with the `code` / `styles` helpers from [Detection](#detection)) or what to review; a row with no hit and nothing to review does not apply. -| Symptom | Cause | Fix | Detect | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Clicking a channel-list row, a search result, a thread-list row or a message's reply button does nothing; no row shows active | Selection and thread opening go through workspace navigation, whose default outside `ChatView` is a no-op (it logs a one-time console warning per operation) | Use `ChatView`, or provide `WorkspaceNavigationProvider` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | `` in the thread ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)) | ``, the channel list or thread list fills the view, the channel or thread beside it is not visible and the page scrolls horizontally | `--str-chat__channel-list-width` and `--str-chat__thread-list-width` default to `100%`, and the lists do not shrink | Set both widths on the `ChatView` root ([section 19](#panel-layout)) | `views=` with `ChannelNavigation` / `ChannelList` / `ThreadList`, and no `--str-chat__channel-list-width` (`styles`) | -| A channel name or image renders empty | `name` / `image` are custom data in `stream-chat` v10: `channel.data.custom.name` | Read `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)) | `.data?.name`, `.data.name`, `.data?.image` | -| A held channel throws "pending disposal" after logout or a user switch | `disconnectUser()` disposes the user's channels | Drop held channels on disconnect ([section 4](#without-chatview)) | `disconnectUser`, `useState` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | `` 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 `` 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 ``. -**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`. +**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 either list works on the parent message too. Both lists pass `onMentionsClick`, `onMentionsHover`, `onUserClick`, `onUserHover`, `renderText`, `formatDate`, `unsafeHTML`, `showAvatar`, `reactionDetailsSort`, `sortReactions` and `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 diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index 797e85260..a229206bb 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -292,7 +292,7 @@ export const DayDivider = ({ message }: { message: LocalMessage }) => { | `markRead` / `MarkReadWrapperOptions` | `channel.markReadViaReporter()` (`channel.markRead()` is now the direct request: it skips the reporter and the `markReadRequest` handler). The unread UI snapshot is separate state on the paginator; there is no `updateChannelUiUnreadState` option. | | `setChannelUnreadUiState` | `channel.messagePaginator.setUnreadSnapshot(partial)` | | `openThread` / `closeThread` | `useWorkspaceNavigation().openThread({ channel, message })` / `.closeThread(threadId)`. Inside a thread, `useCloseThread()`. | - | `onMentionsClick` / `onMentionsHover` | the `onMentionsClick` / `onMentionsHover` props on `MessageList` / `Message` | + | `onMentionsClick` / `onMentionsHover` | the `onMentionsClick` / `onMentionsHover` props on `MessageList` / `VirtualizedMessageList` / `Message` | | `dispatch` | none, removed (there is no React reducer) | Custom send/update/delete/markRead request overrides are registered on `client.config` (see "Per-component request-handler props removed" in `ai-migration-v14-v15.md`). @@ -1426,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, 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. +- Replacement: `onMentionsClick` / `onMentionsHover` on `MessageList` or `VirtualizedMessageList` (drilled to each message and the thread's parent message) or on `Message`; handler type `CustomMentionHandler` `(event, message: LocalMessage)`; read mention metadata from `message` (BC-089). - 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: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` + - `master:src/components/MessageList/MessageList.tsx:108-109,421-422` (`onMentionsClick`/`onMentionsHover` in `PropsDrilledToMessage`); `master:src/components/MessageList/VirtualizedMessageList.tsx:87-88,246-247` (same); `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` @@ -2983,7 +2983,7 @@ const { canReply } = useMessagePermissions(message); - 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. +- Replacement: the context fields are optional and set only when `onMentionsClick` / `onMentionsHover` was passed to `MessageList`, `VirtualizedMessageList` 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) diff --git a/src/components/EventComponent/EventComponent.tsx b/src/components/EventComponent/EventComponent.tsx index 8a341a546..f825fb92b 100644 --- a/src/components/EventComponent/EventComponent.tsx +++ b/src/components/EventComponent/EventComponent.tsx @@ -7,6 +7,10 @@ export type EventComponentProps = TimestampFormatterOptions & { message: LocalMessage & { event?: Event; }; + /** + * Renders `message.html` instead of the text. It is inserted unescaped, so enable this only for + * HTML your server trusts or sanitizes. + */ unsafeHTML?: boolean; }; diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index 667be71e8..bc62d2304 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -85,7 +85,9 @@ export type MessageProps = { /** Comparator to sort reactions. Defaults to first reaction time, else reaction type. */ sortReactions?: ReactionsComparator; /** - * Renders `message.html` instead of the Markdown text. Posting HTML is only allowed server-side. + * Renders `message.html` instead of the Markdown text. Only server-side code can set + * `message.html`. It is inserted unescaped, so enable this only for HTML your server trusts or + * sanitizes: script in it runs in the app with the current user's session. */ unsafeHTML?: boolean; }; diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 5430f0d33..4815a1535 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -84,10 +84,15 @@ import { useLastOwnMessage } from './hooks/useLastOwnMessage'; type PropsDrilledToMessage = | 'additionalMessageComposerProps' | 'formatDate' + | 'onMentionsClick' + | 'onMentionsHover' + | 'onUserClick' + | 'onUserHover' | 'reactionDetailsSort' | 'renderText' | 'showAvatar' - | 'sortReactions'; + | 'sortReactions' + | 'unsafeHTML'; type VirtualizedMessageListPropsForContext = | PropsDrilledToMessage @@ -210,6 +215,10 @@ const VirtualizedMessageListWithContext = ( hideDeletedMessages = false, hideNewMessageSeparator = false, maxTimeBetweenGroupedMessages, + onMentionsClick, + onMentionsHover, + onUserClick, + onUserHover, reactionDetailsSort, renderText, returnAllReadData = false, @@ -221,6 +230,7 @@ const VirtualizedMessageListWithContext = ( sortReactions, stickToBottomScrollBehavior = 'smooth', suppressAutoscroll: suppressAutoscrollFromProps = false, + unsafeHTML, withDateSeparator = false, } = props; const thread = useThreadContext(); @@ -233,10 +243,15 @@ const VirtualizedMessageListWithContext = ( additionalMessageComposerProps, closeReactionSelectorOnClick, formatDate, + onMentionsClick, + onMentionsHover, + onUserClick, + onUserHover, reactionDetailsSort, renderText, showAvatar, sortReactions, + unsafeHTML, }; const threadHead = useThreadHead(messageProps); const [suppressAutoscrollWhileLoadingOlder, setSuppressAutoscrollWhileLoadingOlder] = diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index e0532069a..a2b723248 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -121,6 +121,10 @@ export const messageRenderer = ( messageGroupStyles, MessageSystem, numItemsPrepended, + onMentionsClick, + onMentionsHover, + onUserClick, + onUserHover, ownMessagesDeliveredToOthers, ownMessagesReadByOthers, processedMessages: messageList, @@ -131,6 +135,7 @@ export const messageRenderer = ( sortReactions, unreadMessageCount = 0, UnreadMessagesSeparator, + unsafeHTML, virtuosoRef, } = virtuosoContext; @@ -152,7 +157,9 @@ export const messageRenderer = ( } if (message.type === 'system') { - return MessageSystem ? : null; + return MessageSystem ? ( + + ) : null; } const isFirstUnreadMessage = getIsFirstUnreadMessage({ @@ -184,12 +191,17 @@ export const messageRenderer = ( lastOwnMessage={lastOwnMessage} lastReceivedId={lastReceivedMessageId} message={message} + onMentionsClick={onMentionsClick} + onMentionsHover={onMentionsHover} + onUserClick={onUserClick} + onUserHover={onUserHover} reactionDetailsSort={reactionDetailsSort} readBy={ownMessagesReadByOthers[message.id] || []} renderText={renderText} returnAllReadData={returnAllReadData} showAvatar={showAvatar} sortReactions={sortReactions} + unsafeHTML={unsafeHTML} /> ); diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index b76790ddb..b0a5a73e5 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -227,7 +227,15 @@ describe('VirtualizedMessageList', () => { const CustomThreadHead = vi.fn<(props: MessageProps) => React.ReactElement>(() => (
)); - const renderText = vi.fn(); + const messageProps = { + onMentionsClick: vi.fn(), + onMentionsHover: vi.fn(), + onUserClick: vi.fn(), + onUserHover: vi.fn(), + renderText: vi.fn(), + showAvatar: false, + unsafeHTML: true, + }; const { channel, client } = await createChannel(); await act(() => { @@ -236,7 +244,7 @@ describe('VirtualizedMessageList', () => { - + @@ -245,11 +253,37 @@ describe('VirtualizedMessageList', () => { }); const props = CustomThreadHead.mock.lastCall?.[0]; - expect(props).toEqual(expect.objectContaining({ renderText, showAvatar: false })); + expect(props).toEqual(expect.objectContaining(messageProps)); // Props the list was not given stay unset, so an override's own defaults apply. expect(props).not.toHaveProperty('formatDate'); }); + it('hands the list-wide message props to the item renderer', async () => { + const messageProps = { + onMentionsClick: vi.fn(), + onMentionsHover: vi.fn(), + onUserClick: vi.fn(), + onUserHover: vi.fn(), + unsafeHTML: true, + }; + const { channel, client } = await createChannel(); + virtuosoProps.length = 0; + + await act(() => { + render( + + + + + , + ); + }); + + expect(virtuosoProps[virtuosoProps.length - 1].context).toEqual( + expect.objectContaining(messageProps), + ); + }); + // 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__/VirtualizedMessageListComponents.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx index 961f7a99d..1431892c0 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { EmptyPlaceholder, @@ -10,6 +10,7 @@ import { import type { VirtuosoContext } from '../VirtualizedMessageList'; import { generateMessage, + generateUser, initClientWithChannels, mockComponentContext, mockTranslationContextValue, @@ -22,11 +23,12 @@ import { } from '../../../context'; import type { ComponentContextValue } from '../../../context'; import { Chat } from '../../Chat'; +import { EventComponent } from '../../EventComponent'; import { Channel as ChannelComponent } from '../../Channel'; import { ThreadProvider } from '../../Threads'; import { UnreadMessagesSeparator } from '../UnreadMessagesSeparator'; import type { GroupStyle, RenderedMessage } from '../utils'; -import type { Channel, StreamChat, Thread } from 'stream-chat'; +import type { Channel, LocalMessage, StreamChat, Thread } from 'stream-chat'; import { convertDateToTimestamp } from '../../../mock-builders'; // `EmptyPlaceholder` reads thread-ness from `useThreadContext()`, so provide a truthy thread to @@ -349,6 +351,115 @@ describe('VirtualizedMessageComponents', () => { ]); }); + describe('list-wide message props', () => { + const author = generateUser({ name: 'author' }); + const mentionedUser = generateUser({ name: 'mentioned' }); + + // Renders one message with the default message UI, so the props reach real DOM handlers. + const renderMessage = async ( + message: LocalMessage, + messageProps: Partial, + ) => { + const virtuosoContext = { + ...messageProps, + channel, + messageGroupStyles: {}, + numItemsPrepended, + ownMessagesDeliveredToOthers: {}, + ownMessagesReadByOthers: {}, + processedMessages: [message], + virtuosoRef: { current: {} }, + } as unknown as VirtuosoContext; + let result: ReturnType; + // eslint-disable-next-line require-await + await act(async () => { + result = renderWithChannel( + messageRenderer(virtuosoIndex, undefined, virtuosoContext), + ); + }); + return result!; + }; + + const mentionMessage = () => + generateMessage({ + mentioned_users: [mentionedUser], + text: `@${mentionedUser.name} hello`, + user: author, + }); + + it('should call onMentionsClick and onMentionsHover with the message', async () => { + const onMentionsClick = vi.fn(); + const onMentionsHover = vi.fn(); + const message = mentionMessage(); + await renderMessage(message, { onMentionsClick, onMentionsHover }); + + const text = screen.getByTestId('message-text-inner-wrapper'); + fireEvent.click(text); + fireEvent.mouseOver(text); + + expect(onMentionsClick).toHaveBeenCalledTimes(1); + expect(onMentionsClick).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: message.id }), + ); + expect(onMentionsHover).toHaveBeenCalledTimes(1); + expect(onMentionsHover).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: message.id }), + ); + }); + + it('should call onUserClick and onUserHover with the message author', async () => { + const onUserClick = vi.fn(); + const onUserHover = vi.fn(); + await renderMessage(generateMessage({ user: author }), { + onUserClick, + onUserHover, + }); + + const avatar = screen.getByTitle(author.name as string); + fireEvent.click(avatar); + fireEvent.mouseOver(avatar); + + expect(onUserClick).toHaveBeenCalledTimes(1); + expect(onUserClick).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: author.id }), + ); + expect(onUserHover).toHaveBeenCalledTimes(1); + expect(onUserHover).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: author.id }), + ); + }); + + it('should render the message HTML when unsafeHTML is set', async () => { + await renderMessage( + generateMessage({ + html: '', + user: author, + }), + { unsafeHTML: true }, + ); + + expect(screen.getByTestId('custom-html')).toBeInTheDocument(); + }); + + it('should render the message text instead of its HTML without unsafeHTML', async () => { + await renderMessage( + generateMessage({ + html: '', + text: 'plain text', + user: author, + }), + {}, + ); + + expect(screen.queryByTestId('custom-html')).not.toBeInTheDocument(); + expect(screen.getByText('plain text')).toBeInTheDocument(); + }); + }); + it('should render nothing if MessageSystem component is undefined for system messages', () => { const virtuosoContext = { numItemsPrepended, @@ -384,6 +495,48 @@ describe('VirtualizedMessageComponents', () => { expect(screen.getByText(text)).toBeInTheDocument(); }); + describe('system message HTML', () => { + // Renders a system message that has both HTML and text with the default MessageSystem. + const renderSystemMessage = (unsafeHTML?: boolean) => { + const virtuosoContext = { + MessageSystem: EventComponent, + numItemsPrepended, + processedMessages: [ + generateMessage({ + html: '', + text: 'system text', + type: 'system', + }), + ], + unsafeHTML, + }; + render( + messageRenderer( + virtuosoIndex, + undefined, + virtuosoContext as unknown as VirtuosoContext, + ), + ); + }; + + it("should render a system message's HTML when unsafeHTML is set", () => { + renderSystemMessage(true); + + expect(screen.getByTestId('custom-html')).toBeInTheDocument(); + expect(screen.queryByText('system text')).not.toBeInTheDocument(); + }); + + it.each([false, undefined])( + "should render a system message's text when unsafeHTML is %s", + (unsafeHTML) => { + renderSystemMessage(unsafeHTML); + + expect(screen.getByText('system text')).toBeInTheDocument(); + expect(screen.queryByTestId('custom-html')).not.toBeInTheDocument(); + }, + ); + }); + it('should render nothing if DateSeparator component is undefined for custom message type date', () => { const virtuosoContext = { numItemsPrepended, diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index 02aa5209a..5fdccf24e 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -104,7 +104,9 @@ export type MessageContextValue = { /** Comparator to sort reactions. Defaults to first reaction time if both have one, else type. */ sortReactions?: ReactionsComparator; /** - * Renders `message.html` instead of the Markdown text. Posting HTML is allowed server-side only. + * Renders `message.html` instead of the Markdown text. Only server-side code can set + * `message.html`. It is inserted unescaped, so enable this only for HTML your server trusts or + * sanitizes: script in it runs in the app with the current user's session. */ unsafeHTML?: boolean; /** From 8c9e1a3c464a4eec2a2d3aa9994bd41134fba332 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Nov 2026 15:18:06 +0100 Subject: [PATCH 2/3] fix(VirtualizedMessageList): give the giphy preview the list props, keep system text without html The separate giphy preview now gets the same list-wide message props as the other messages. EventComponent renders the message text when unsafeHTML is set but the message has no html, as MessageText does. --- ai-docs/v15-breaking-changes.md | 2 +- .../EventComponent/EventComponent.tsx | 8 +-- .../__tests__/EventComponent.test.tsx | 9 ++++ .../MessageList/GiphyPreviewMessage.tsx | 24 ++++----- .../MessageList/VirtualizedMessageList.tsx | 11 +++- .../__tests__/VirtualizedMessageList.test.tsx | 51 +++++++++++++++++++ .../MessageList/hooks/useThreadHead.tsx | 11 +--- .../MessageList/omitUndefinedProps.ts | 8 +++ 8 files changed, 95 insertions(+), 29 deletions(-) create mode 100644 src/components/MessageList/omitUndefinedProps.ts diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index a229206bb..80e5b8e5b 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -1426,7 +1426,7 @@ 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` or `VirtualizedMessageList` (drilled to each message and the thread's parent message) or on `Message`; handler type `CustomMentionHandler` `(event, message: LocalMessage)`; read mention metadata from `message` (BC-089). +- Replacement: `onMentionsClick` / `onMentionsHover` on `MessageList` or `VirtualizedMessageList` (drilled to each message, the thread's parent message and the separate giphy preview) or on `Message`; handler type `CustomMentionHandler` `(event, message: LocalMessage)`; read mention metadata from `message` (BC-089). - 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:108-109,421-422` (`onMentionsClick`/`onMentionsHover` in `PropsDrilledToMessage`); `master:src/components/MessageList/VirtualizedMessageList.tsx:87-88,246-247` (same); `master:src/components/Message/types.ts:54-56` diff --git a/src/components/EventComponent/EventComponent.tsx b/src/components/EventComponent/EventComponent.tsx index f825fb92b..5b803ef67 100644 --- a/src/components/EventComponent/EventComponent.tsx +++ b/src/components/EventComponent/EventComponent.tsx @@ -8,8 +8,8 @@ export type EventComponentProps = TimestampFormatterOptions & { event?: Event; }; /** - * Renders `message.html` instead of the text. It is inserted unescaped, so enable this only for - * HTML your server trusts or sanitizes. + * Renders `message.html`, when the message has one, instead of the text. It is inserted + * unescaped, so enable this only for HTML your server trusts or sanitizes. */ unsafeHTML?: boolean; }; @@ -24,9 +24,9 @@ const UnMemoizedEventComponent = (props: EventComponentProps) => { return (
- {unsafeHTML ? ( + {unsafeHTML && message.html ? (
) : ( diff --git a/src/components/EventComponent/__tests__/EventComponent.test.tsx b/src/components/EventComponent/__tests__/EventComponent.test.tsx index f4a50ae42..d1840d62c 100644 --- a/src/components/EventComponent/__tests__/EventComponent.test.tsx +++ b/src/components/EventComponent/__tests__/EventComponent.test.tsx @@ -93,6 +93,15 @@ describe('EventComponent', () => { expect(unsafeDiv.innerHTML).toBe('html event'); }); + it('should render the text when unsafeHTML is true but the message has no HTML', async () => { + await renderComponent({ + props: { message: { ...message, text: 'system event' }, unsafeHTML: true }, + }); + const systemMsg = screen.getByTestId(SYSTEM_MSG_TEST_ID); + expect(systemMsg.querySelector('[data-unsafe-inner-html]')).not.toBeInTheDocument(); + expect(systemMsg).toHaveTextContent('system event'); + }); + it('should render system event text content (no timestamp displayed)', async () => { await renderComponent({ props: { message: { ...message, text: 'a system message' } }, diff --git a/src/components/MessageList/GiphyPreviewMessage.tsx b/src/components/MessageList/GiphyPreviewMessage.tsx index c6d4b7d3a..8f5ce5cd7 100644 --- a/src/components/MessageList/GiphyPreviewMessage.tsx +++ b/src/components/MessageList/GiphyPreviewMessage.tsx @@ -1,18 +1,16 @@ import React from 'react'; import { Message } from '../Message/Message'; -import type { LocalMessage } from 'stream-chat'; +import type { MessageProps } from '../Message/types'; -export type GiphyPreviewMessageProps = { - message: LocalMessage; -}; +/** + * Props of the giphy preview: the message plus the list-wide props every message in the list + * gets. + */ +export type GiphyPreviewMessageProps = MessageProps; -export const GiphyPreviewMessage = (props: GiphyPreviewMessageProps) => { - const { message } = props; - - return ( -
- -
- ); -}; +export const GiphyPreviewMessage = (props: GiphyPreviewMessageProps) => ( +
+ +
+); diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 4815a1535..6e52a2774 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -33,6 +33,7 @@ import type { RenderedMessage, } from './utils'; import { getLastReceived, getMessageGroupStyles, processMessages } from './utils'; +import { omitUndefinedProps } from './omitUndefinedProps'; import type { MessageProps } from '../Message'; import { UnreadMessagesNotification as DefaultUnreadMessagesNotification } from './UnreadMessagesNotification'; import { @@ -235,7 +236,8 @@ const VirtualizedMessageListWithContext = ( } = props; const thread = useThreadContext(); const isThreadList = !!thread; - // The list-wide props every message gets, the thread's parent message included. + // The list-wide props every message gets, the thread's parent message and the separate giphy + // preview included. const messageProps: Pick< VirtualizedMessageListProps, PropsDrilledToMessage | 'closeReactionSelectorOnClick' @@ -671,7 +673,12 @@ const VirtualizedMessageListWithContext = ( scrollToBottom={scrollToBottom} /> )} - {giphyPreviewMessage && } + {giphyPreviewMessage && ( + + )} ); diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index b0a5a73e5..7fd47152b 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -26,6 +26,7 @@ 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'; +import type { GiphyPreviewMessageProps } from '../GiphyPreviewMessage'; const threadWithParentMessage = () => fromPartial({ @@ -258,6 +259,56 @@ describe('VirtualizedMessageList', () => { expect(props).not.toHaveProperty('formatDate'); }); + it('gives the separate giphy preview the list-wide message props', async () => { + const CustomGiphyPreviewMessage = vi.fn< + (props: GiphyPreviewMessageProps) => React.ReactElement + >(() =>
); + const messageProps = { + onMentionsClick: vi.fn(), + onUserClick: vi.fn(), + unsafeHTML: true, + }; + const user = generateUser({ id: 'id' }); + const giphyPreview = generateMessage({ + command: 'giphy', + id: 'giphy-preview', + type: 'ephemeral', + user, + }); + const mockedChannel = generateChannel({ + members: [generateMember({ user })], + messages: [giphyPreview], + }); + const client = await getTestClientWithUser({ id: user.id }); + useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); + const channel = client.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + await act(() => { + render( + + + + + + + , + ); + }); + + const props = CustomGiphyPreviewMessage.mock.lastCall?.[0]; + expect(props).toEqual( + expect.objectContaining({ + ...messageProps, + message: expect.objectContaining({ id: giphyPreview.id }), + }), + ); + // Props the list was not given stay unset, so an override's own defaults apply. + expect(props).not.toHaveProperty('formatDate'); + }); + it('hands the list-wide message props to the item renderer', async () => { const messageProps = { onMentionsClick: vi.fn(), diff --git a/src/components/MessageList/hooks/useThreadHead.tsx b/src/components/MessageList/hooks/useThreadHead.tsx index 6d5b5f2e7..b15c5f9a9 100644 --- a/src/components/MessageList/hooks/useThreadHead.tsx +++ b/src/components/MessageList/hooks/useThreadHead.tsx @@ -5,6 +5,8 @@ import { useComponentContext } from '../../../context'; import { useThreadContext } from '../../Threads'; import { useStateStore } from '../../../store'; +import { omitUndefinedProps } from '../omitUndefinedProps'; + import type { ThreadState } from 'stream-chat'; import type { MessageProps } from '../../Message/types'; @@ -13,15 +15,6 @@ const parentMessageSelector = ({ parentMessage }: ThreadState) => ({ parentMessa /** 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. * diff --git a/src/components/MessageList/omitUndefinedProps.ts b/src/components/MessageList/omitUndefinedProps.ts new file mode 100644 index 000000000..1b4a3f3ee --- /dev/null +++ b/src/components/MessageList/omitUndefinedProps.ts @@ -0,0 +1,8 @@ +/** + * Keeps only the props that are set. A component override that sets a prop and then spreads its + * props (``) keeps its own value. + */ +export const omitUndefinedProps = (props: Props = {} as Props) => + Object.fromEntries( + Object.entries(props).filter(([, value]) => value !== undefined), + ) as Partial; From dfceeadb9dec6cd74cd53e9ec18ccf91056c5443 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Nov 2026 10:09:38 +0100 Subject: [PATCH 3/3] fix(MessageList): pass unsafeHTML to MessageSystem only when it is set Both lists now leave unsafeHTML off a custom MessageSystem when the list doesn't set it, so an override that sets its own value keeps it. The migration guide notes that customMessageRenderer has to pass the list's message props itself. --- ai-docs/ai-migration-v14-v15.md | 2 +- .../VirtualizedMessageListComponents.tsx | 3 +- .../__tests__/MessageList.test.tsx | 22 ++++++++++ .../VirtualizedMessageListComponents.test.tsx | 41 +++++++++++++++++++ src/components/MessageList/renderMessages.tsx | 6 ++- 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 6e0972359..d433795e4 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1147,7 +1147,7 @@ export const ChannelWithAttachments = ({ channel }: { channel: StreamChannel }) Inside an attachment component, read them with `useAttachmentContext()`. -**Mention handlers.** The handler type is `(event, message)`; the v14 `mentioned_users` argument is removed, read the metadata from `message` ([section 12](#mention-handlers-receive-event-message)). `VirtualizedMessageList` takes the same props, which v14 virtualized lists got only from `Channel`. +**Mention handlers.** The handler type is `(event, message)`; the v14 `mentioned_users` argument is removed, read the metadata from `message` ([section 12](#mention-handlers-receive-event-message)). `VirtualizedMessageList` takes the same props, which v14 virtualized lists got only from `Channel`. With `VirtualizedMessageList`'s `customMessageRenderer`, your renderer renders every item (messages, system messages, date separators), so the list's message props (these handlers, `unsafeHTML`, `renderText`, …) reach them only if you pass them to the `Message` or `MessageSystem` it creates. The list still renders the thread's parent message and the giphy preview itself, and those get the props. ```tsx // v14 diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index a2b723248..e8f5f29cd 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -10,6 +10,7 @@ import { Message } from '../Message'; import { useComponentContext } from '../../context'; import type { GroupStyle, RenderedMessage } from './utils'; import { getIsFirstUnreadMessage, isDateSeparatorMessage, isIntroMessage } from './utils'; +import { omitUndefinedProps } from './omitUndefinedProps'; import type { VirtuosoContext } from './VirtualizedMessageList'; import type { UnknownType } from '../../types/types'; import { useThreadContext } from '../Threads'; @@ -158,7 +159,7 @@ export const messageRenderer = ( if (message.type === 'system') { return MessageSystem ? ( - + ) : null; } diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 39dd8d494..e2bd1e250 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -33,6 +33,8 @@ import { MESSAGE_LIST_MAIN_PANEL_CLASS } from '../MessageListMainPanel'; import { ThreadProvider } from '../../Threads'; import { useChannel, useMessageContext, WithComponents } from '../../../context'; import { EmptyStateIndicator as EmptyStateIndicatorMock } from '../../EmptyStateIndicator'; +import { EventComponent } from '../../EventComponent'; +import type { EventComponentProps } from '../../EventComponent'; import { mockedApiResponse } from '../../../mock-builders/api/utils'; import { nanoid } from 'nanoid'; import { StateStore } from '@stream-io/state-store'; @@ -644,6 +646,26 @@ describe('MessageList', () => { }); }); + it("should keep a MessageSystem override's own unsafeHTML when the list doesn't set it", async () => { + const MessageSystem = (props: EventComponentProps) => ( + + ); + const system = generateMessage({ + html: '', + text: 'system text', + type: 'system', + }); + + renderComponent({ + channelProps: { channel }, + chatClient, + components: { MessageSystem }, + msgListProps: { messages: [system] }, + }); + + expect(await screen.findByTestId('custom-html')).toBeInTheDocument(); + }); + it('should give the system message list item the str-chat__li class', async () => { const system = generateMessage({ text: 'system message in a list item', diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx index 1431892c0..8a5e137ef 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx @@ -24,6 +24,7 @@ import { import type { ComponentContextValue } from '../../../context'; import { Chat } from '../../Chat'; import { EventComponent } from '../../EventComponent'; +import type { EventComponentProps } from '../../EventComponent'; import { Channel as ChannelComponent } from '../../Channel'; import { ThreadProvider } from '../../Threads'; import { UnreadMessagesSeparator } from '../UnreadMessagesSeparator'; @@ -526,6 +527,46 @@ describe('VirtualizedMessageComponents', () => { expect(screen.queryByText('system text')).not.toBeInTheDocument(); }); + describe('with a MessageSystem override that sets its own unsafeHTML', () => { + const MessageSystem = (props: EventComponentProps) => ( + + ); + const renderWithOverride = (unsafeHTML?: boolean) => { + const virtuosoContext = { + MessageSystem, + numItemsPrepended, + processedMessages: [ + generateMessage({ + html: '', + text: 'system text', + type: 'system', + }), + ], + unsafeHTML, + }; + render( + messageRenderer( + virtuosoIndex, + undefined, + virtuosoContext as unknown as VirtuosoContext, + ), + ); + }; + + it("should keep the override's value when the list doesn't set unsafeHTML", () => { + renderWithOverride(); + + expect(screen.getByTestId('custom-html')).toBeInTheDocument(); + }); + + it('should pass unsafeHTML false from the list over the override', () => { + renderWithOverride(false); + + expect(screen.getByText('system text')).toBeInTheDocument(); + expect(screen.queryByTestId('custom-html')).not.toBeInTheDocument(); + }); + }); + it.each([false, undefined])( "should render a system message's text when unsafeHTML is %s", (unsafeHTML) => { diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx index 16cc133cf..f61579516 100644 --- a/src/components/MessageList/renderMessages.tsx +++ b/src/components/MessageList/renderMessages.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import React, { Fragment } from 'react'; import type { GroupStyle, RenderedMessage } from './utils'; import { getIsFirstUnreadMessage, isDateSeparatorMessage, isIntroMessage } from './utils'; +import { omitUndefinedProps } from './omitUndefinedProps'; import type { MessageProps } from '../Message'; import { Message } from '../Message'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; @@ -103,7 +104,10 @@ export function defaultRenderMessages({ data-message-id={message.id} key={message.id || String(message.created_at)} > - + , ); } else {