diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index c34c07f20..d433795e4 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) | @@ -272,52 +272,52 @@ Old → new for the most common changes. Ctrl-F the v14 name; the section has th These compile cleanly, or are ignored in plain JS, and change behavior at runtime. Check each one explicitly; the absence of an error does not mean the app is migrated. **Detect** lists `rg` patterns (run them with the `code` / `styles` helpers from [Detection](#detection)) or what to review; a row with no hit and nothing to review does not apply. -| Symptom | Cause | Fix | Detect | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Clicking a channel-list row, a search result, a thread-list row or a message's reply button does nothing; no row shows active | Selection and thread opening go through workspace navigation, whose default outside `ChatView` is a no-op (it logs a one-time console warning per operation) | Use `ChatView`, or provide `WorkspaceNavigationProvider` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | `` in the thread ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)) | ``, the channel list or thread list fills the view, the channel or thread beside it is not visible and the page scrolls horizontally | `--str-chat__channel-list-width` and `--str-chat__thread-list-width` default to `100%`, and the lists do not shrink | Set both widths on the `ChatView` root ([section 19](#panel-layout)) | `views=` with `ChannelNavigation` / `ChannelList` / `ThreadList`, and no `--str-chat__channel-list-width` (`styles`) | -| A channel name or image renders empty | `name` / `image` are custom data in `stream-chat` v10: `channel.data.custom.name` | Read `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)) | `.data?.name`, `.data.name`, `.data?.image` | -| A held channel throws "pending disposal" after logout or a user switch | `disconnectUser()` disposes the user's channels | Drop held channels on disconnect ([section 4](#without-chatview)) | `disconnectUser`, `useState` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | `` 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 bf7e553a7..7a2376482 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -293,7 +293,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`). @@ -1427,10 +1427,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, 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: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` @@ -2984,7 +2984,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..5b803ef67 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`, 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; }; @@ -20,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/Message/types.ts b/src/components/Message/types.ts index 1e6435298..42d6e962e 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -83,7 +83,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/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 5430f0d33..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 { @@ -84,10 +85,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 +216,10 @@ const VirtualizedMessageListWithContext = ( hideDeletedMessages = false, hideNewMessageSeparator = false, maxTimeBetweenGroupedMessages, + onMentionsClick, + onMentionsHover, + onUserClick, + onUserHover, reactionDetailsSort, renderText, returnAllReadData = false, @@ -221,11 +231,13 @@ const VirtualizedMessageListWithContext = ( sortReactions, stickToBottomScrollBehavior = 'smooth', suppressAutoscroll: suppressAutoscrollFromProps = false, + unsafeHTML, withDateSeparator = false, } = 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' @@ -233,10 +245,15 @@ const VirtualizedMessageListWithContext = ( additionalMessageComposerProps, closeReactionSelectorOnClick, formatDate, + onMentionsClick, + onMentionsHover, + onUserClick, + onUserHover, reactionDetailsSort, renderText, showAvatar, sortReactions, + unsafeHTML, }; const threadHead = useThreadHead(messageProps); const [suppressAutoscrollWhileLoadingOlder, setSuppressAutoscrollWhileLoadingOlder] = @@ -656,7 +673,12 @@ const VirtualizedMessageListWithContext = ( scrollToBottom={scrollToBottom} /> )} - {giphyPreviewMessage && } + {giphyPreviewMessage && ( + + )} ); diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index e0532069a..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'; @@ -121,6 +122,10 @@ export const messageRenderer = ( messageGroupStyles, MessageSystem, numItemsPrepended, + onMentionsClick, + onMentionsHover, + onUserClick, + onUserHover, ownMessagesDeliveredToOthers, ownMessagesReadByOthers, processedMessages: messageList, @@ -131,6 +136,7 @@ export const messageRenderer = ( sortReactions, unreadMessageCount = 0, UnreadMessagesSeparator, + unsafeHTML, virtuosoRef, } = virtuosoContext; @@ -152,7 +158,9 @@ export const messageRenderer = ( } if (message.type === 'system') { - return MessageSystem ? : null; + return MessageSystem ? ( + + ) : null; } const isFirstUnreadMessage = getIsFirstUnreadMessage({ @@ -184,12 +192,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__/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__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index b76790ddb..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({ @@ -227,7 +228,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 +245,7 @@ describe('VirtualizedMessageList', () => { - + @@ -245,11 +254,87 @@ 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('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(), + 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..8a5e137ef 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,13 @@ import { } from '../../../context'; 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'; 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 +352,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 +496,88 @@ 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(); + }); + + 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) => { + 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/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; 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 { diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index a309537a6..45d8cf079 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -102,7 +102,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; /**