From 5807521b348cf26ea37fb9e41b1213d7b4739525 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Nov 2026 23:03:59 +0100 Subject: [PATCH 1/5] fix!: remove props that have no effect, wire up the ones with a clear meaning Removes props that type-check but do nothing: additionalMessageComposerProps on Message, MessageList and VirtualizedMessageList; MessageReactions.reverse; MessageReactionsDetail.sort and dialogId; MessageComposer emojiSearchIndex and parent (and the context fields, plus the unset audioRecordingConfig); the emojiSearchIndex component slot; ChannelListItem.activeChannel; ChatView.entityInferrers; WithDragAndDropUpload.acceptedFiles. Wires up MessageComposer generateRecordingTitle, and the className and onPaste of additionalTextareaProps (onPaste runs before the default paste handling and skips it on preventDefault). An inline audioRecordingConfig no longer creates a new recorder on every render, which cancelled a recording in progress: the recorder is rebuilt only when the config's values change. The value comparison is exported as useSameValuesReference, next to useStableCallback. Ticket: REACT-1277 BREAKING CHANGE: the removed props are compile errors. None of them changed anything in v14. See BC-105 to BC-108 in ai-docs/v15-breaking-changes.md and the migration guide. --- ai-docs/ai-migration-v14-v15.md | 82 ++++++- ai-docs/v15-breaking-changes.md | 222 ++++++++++++++---- examples/tutorial/src/7-emoji-picker/App.tsx | 4 +- examples/vite/src/App.tsx | 1 - .../ChannelListItem/ChannelListItem.tsx | 2 - .../__tests__/ChannelListItem.test.tsx | 97 ++------ src/components/Message/Message.tsx | 1 - src/components/Message/MessageUI.tsx | 2 +- src/components/Message/types.ts | 6 - .../MessageComposer/MessageComposer.tsx | 28 +-- .../MessageComposer/WithDragAndDropUpload.tsx | 2 +- .../useMessageComposerBindings.test.tsx | 117 +++++++++ .../hooks/useCreateMessageComposerContext.ts | 8 - .../hooks/useMessageComposerBindings.ts | 23 +- src/components/MessageList/MessageList.tsx | 2 - .../MessageList/VirtualizedMessageList.tsx | 3 - .../VirtualizedMessageListComponents.tsx | 2 - src/components/Reactions/MessageReactions.tsx | 2 - .../Reactions/MessageReactionsDetail.tsx | 3 +- .../TextareaComposer/TextareaComposer.tsx | 16 +- .../__tests__/TextareaComposer.test.tsx | 84 ++++++- src/context/ComponentContext.tsx | 6 - src/context/MessageComposerContext.tsx | 3 +- src/plugins/SlotLayout/ChatView.tsx | 12 - .../__tests__/useSameValuesReference.test.ts | 88 +++++++ src/utils/index.ts | 1 + src/utils/useSameValuesReference.ts | 50 ++++ 27 files changed, 657 insertions(+), 210 deletions(-) create mode 100644 src/components/MessageComposer/hooks/__tests__/useMessageComposerBindings.test.tsx create mode 100644 src/utils/__tests__/useSameValuesReference.test.ts create mode 100644 src/utils/useSameValuesReference.ts diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 38937f17a0..1afbb5ddc0 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-097`) 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-108`) 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. @@ -137,7 +137,7 @@ code '\b(setActiveChannel|openThread|closeThread|setActiveThread|focusedMessage) # §5 : required channel, watching, props that moved code ' { | `setActiveChannel`, `watchers` | `useWorkspaceNavigation().openChannel(channel, { event })`, or the `onSelect` prop ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | | `channelUpdateCount`, `key` | None; the item subscribes to the channel's stores | | `active` | Unchanged prop; it now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. `ChannelListItem` passes the resolved value to `ChannelListItemUI` as `active`. The SDK's CSS highlights `.str-chat__channel-list-item[aria-selected=true]` only (v14 also matched `[aria-pressed=true]`), so a custom row that keeps the SDK class sets `role="option"` and `aria-selected={active}` | +| `activeChannel` | `active`. v14 ignored the prop too: the item compared `useChatContext().channel`. A custom `ChannelListItemUI` that read `props.activeChannel` reads `active` | The `getLatestMessagePreview` and `getLatestMessagePreviewText` utilities are still exported; only their `t` / `userLanguage` parameter types changed. Prefer `WithComponents` for the slot override: it merges over the parent context, while `ComponentProvider` replaces it. Neither `Chat` nor `Channel` takes component props. @@ -1498,7 +1500,7 @@ export const CustomItem = ({ **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`. +**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`). 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 @@ -2439,6 +2441,30 @@ export const List = () => ( ); ``` +### `additionalMessageComposerProps` is removed from messages and message lists + +`Message`, `MessageList` and `VirtualizedMessageList` no longer take `additionalMessageComposerProps`. Passing it is a compile error; in untyped code it is ignored. Nothing changes on screen: no message component read it in v14 either, so delete it. If the props were meant for the composer, pass them to the `MessageComposer` you render; that turns them on, because v14 never applied them. A message is edited in that composer. + +```tsx +// v14 +<> + + + +``` + +```tsx +// the prop did nothing in v14: delete it +<> + + + +``` + +### `MessageReactions.reverse` and `MessageReactionsDetail.sort` / `dialogId` are removed + +The default components ignored all three in v14 too, so delete them. Passing them, or reading them in a custom `MessageReactions` or `MessageReactionsDetail`, is a compile error; the default `MessageUI` no longer passes `reverse` to a `MessageReactions` override. Order the reaction types with `sortReactions` (on `Message`, the message lists or `MessageReactions`). Order the reacting users in the detail with `reactionDetailsSort`, a `SortParamRequest[]` ([section 17](#17-other-stream-chat-v10-data-shapes)), on the same components or on `MessageReactionsDetail`. The detail's dialog id is `MessageReactionsDetail.getDialogId({ messageId })`. + ### `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. @@ -2822,6 +2848,46 @@ Only the four built-in action types (`uploadFile`, `createPoll`, `addLocation`, `useAttachmentManagerState().isUploadEnabled` still does not re-render on an `own_capabilities` change; subscribe with `useChannelCapabilities` where that matters. The attachment selectors already do. +### `emojiSearchIndex` and `parent` are removed from `MessageComposer` + +`MessageComposer` no longer takes `emojiSearchIndex` or `parent`, the `emojiSearchIndex` override key is removed, and `useMessageComposerContext()` no longer returns `emojiSearchIndex`, `parent` or `audioRecordingConfig`. In v14 they only reached the composer context, and nothing read them there, so emoji autocomplete already needed the middleware. Register it on every composer, and drop the props: + +```tsx +// v14 + + + +``` + +```tsx +import type { TextComposerMiddleware } from 'stream-chat'; +import { createTextComposerEmojiMiddleware } from 'stream-chat-react/emojis'; +import { SearchIndex } from 'emoji-mart'; + +// Before the composers are created, e.g. right after creating the client. +client.config.setSetupFunction('messageComposer', ({ composer }) => { + composer.textComposer.middlewareExecutor.insert({ + middleware: [ + createTextComposerEmojiMiddleware(SearchIndex) as TextComposerMiddleware, + ], + position: { before: 'stream-io/text-composer/mentions-middleware' }, + unique: true, + }); +}); + +; +``` + +A thread's composer already belongs to the thread. Where you need the parent message, subscribe with `useStateStore(useThreadContext()?.state, selector)` and read `parentMessage`. `audioRecordingConfig` stays a `MessageComposer` prop. + +New: `MessageComposer` takes `generateRecordingTitle(mimeType)` to name voice recording files. + +Behavior change, listed in [Breaks that do not fail to compile](#breaks-that-do-not-fail-to-compile): `additionalTextareaProps.className` is added to the textarea's classes, and `additionalTextareaProps.onPaste` (or a `TextareaComposer` `onPaste`) runs before the composer's paste handling; calling `event.preventDefault()` in it skips that handling. v14 ignored both. + +### `WithDragAndDropUpload.acceptedFiles` is removed + +Delete the prop; v14 ignored it too. The drop zone and the file picker (`UploadButton`) accept the types in the composer config, `attachments.acceptedFiles`, set for example in `client.config.setSetupFunction('messageComposer', ...)` or with `MessageComposer.updateConfig()`. + ### `useAttachmentManagerState`: `hasCustomDoUploadRequest` → `customCdn` `hasCustomDoUploadRequest` was only consulted as a proxy for "do uploads bypass Stream's storage rules", and an upload function that still posts to Stream stays subject to them. Read `customCdn`, the flag that decides. In plain JS the removed field is `undefined`, so a branch on it always takes the "no custom upload" path. To ask whether a custom upload function is installed, read `messageComposer.attachmentManager.hasCustomDoUploadRequest`. @@ -3437,7 +3503,7 @@ Run after every section is applied. 2. **Leftover identifiers.** Every v14 name below was removed or renamed. In a plain-JS app this grep is the only thing that finds them. It must print nothing outside comments, changelogs and app identifiers that only share the name (record those in the report). Uses the `code` / `styles` helpers from [Detection](#detection). ```bash - code '\b(WindowProps|ChannelStateContext|ChannelStateContextValue|ChannelStateProvider|useChannelStateContext|ChannelActionContext|ChannelActionContextValue|ChannelActionProvider|useChannelActionContext|ChannelNotifications|MarkReadWrapperOptions|RetrySendMessage|TypingContext|TypingContextValue|TypingProvider|useTypingContext|ChannelUnreadUiState|channelUnreadUiState|setChannelUnreadUiState|updateChannelUiUnreadState|useThreadsViewContext|ThreadsViewContextValue|ThreadAdapter|activeChatView|setActiveChatView|setActiveChannel|setActiveChannelOnMount|customActiveChannel|channelsQueryState|latestMessageDatesByChannels|useChannelEditMessageHandler|useChannelMentionsHandler|ChannelListUI|ChannelListUIProps|usePaginatedChannels|useConnectionRecoveredListener|CustomQueryChannelParams|CustomQueryChannelsFn|EffectiveQueryParams|moveChannelUpwards|findLastPinnedChannelIndex|extractSortValue|isChannelPinned|isChannelArchived|shouldConsiderPinnedChannels|shouldConsiderArchivedChannels|allowNewMessagesFromUnfilteredChannels|lockChannelOrder|showChannelSearch|sendChannelsToList|renderChannels|channelRenderFilterFn|customQueryChannels|recoveryThrottleIntervalMs|onMessageNewHandler|channelUpdateCount|useLegacyThreadContext|additionalMessageComposerProps|additionalMessageListProps|additionalVirtualizedMessageListProps|additionalParentMessageProps|enableDateSeparator|disableDateSeparator|HeaderStartContent|MESSAGE_ACTIONS|OPTIONAL_MESSAGE_ACTIONS|getMessageActions|MessageActionsArray|actionsEnabled|messageIsUnread|initializeOnMount|channelQueryOptions|markReadOnMount|activeUnreadHandler|skipMessageDataMemoization|allowConcurrentAudioPlayback|allowConcurrentPlayback|doSendMessageRequest|doUpdateMessageRequest|doDeleteMessageRequest|doMarkReadRequest|overrideSubmitHandler|preventClearingOnUnmount|loadMoreNewer|loadMoreThread|loadingMoreNewer|jumpToLatestMessage|jumpToFirstUnreadMessage|highlightedMessageId|threadSuppressAutoscroll|loadingMoreForJumpToChannelMessage|messageLimit|hasPendingUploadState|recoverStateOnReconnect|wsUrlParams|isLocalUnreadCountEnabled|StreamChatOptions|isLanguageSupported|SupportedTranslations|DateFormatterOptions|getTranslators|registerSetLanguageCallback|getAvailableLanguages|geti18Instance|deTranslations|enTranslations|esTranslations|frTranslations|hiTranslations|itTranslations|jaTranslations|koTranslations|nlTranslations|ptTranslations|ruTranslations|trTranslations|ChannelSort|UserSort|ReactionSort|WatcherState|unBlockUser|ReminderResponse|CommandResponse|DefaultCommandData|CustomCommandData|chatContainer|useUserRole|disableQuotedMessages|MentionedUserEventHandler|mentionedUser|noGroupByUser|shouldGroupByUser|defaultItemHeight|scrollSeekPlaceHolder|endOfGroup|firstOfGroup|groupedByUser)\b| `useWorkspaceNavigation().openChannel(channel, { event })` or the `onSelect` prop (BC-013). - `channelUpdateCount` / `key`: none needed; the item subscribes to the channel's stores. - `active` now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. + - `activeChannel`: v14 ignored the prop too. The item compared `useChatContext().channel` (BC-013), and `ChannelList` passed `activeChannel` only for `ChannelListItemUI` to read. A custom `ChannelListItemUI` reads `active` instead. - Evidence: - - `release-v14:src/components/ChannelListItem/ChannelListItem.tsx:37,39,58,62,68,72,74`; `release-v14:src/components/ChannelListItem/ChannelListItemTimestamp.tsx:9,12` + - `release-v14:src/components/ChannelListItem/ChannelListItem.tsx:37,39,56,58,62,68,72,74`, `:92` (`channel: activeChannel` from `useChatContext`); `release-v14:src/components/ChannelListItem/ChannelListItemTimestamp.tsx:9,12` - `master:src/components/ChannelListItem/ChannelListItem.tsx:22-65,88-109`; `master:src/components/ChannelListItem/ChannelListItemTimestamp.tsx:10,14`; `master:src/components/ChannelListItem/ChannelListItemUI.tsx:95-102,152,160-161`; `master:src/components/ChannelListItem/utils.tsx:260,324`; `master:src/context/ComponentContext.tsx:143` - commit: `99cbe6704` (PR #3237) - corpus: `docs/03-ui-cookbook__02-channel-list__01-channel_list_preview__2.tsx:34` TS2322 (`lastMessage` on `ChannelListItemTimestampProps`); `examples/tutorial/src/6-custom-ui-components/App.tsx:42` TS2339 (`latestMessagePreview`), `:44` (`setActiveChannel`) @@ -3098,6 +3104,48 @@ export const List = () => ( ); ``` + + +### BC-105: `additionalMessageComposerProps` is removed from `MessageProps`, `MessageListProps` and `VirtualizedMessageListProps` + +- Status: confirmed +- Area: message / message list +- User impact: compile error on passing `additionalMessageComposerProps` to `Message`, `MessageList` or `VirtualizedMessageList`, and on reading it in a custom `ThreadHead`. In untyped code it is ignored. Nothing changes on screen: no message component read it in v14 either. The lists passed it to every `Message`, which passed it on to the memoized message, and nothing rendered a composer with it. +- Old API: + - `MessageProps.additionalMessageComposerProps?: MessageComposerProps`, picked into `MessageListProps` and `VirtualizedMessageListProps` +- Replacement: delete it. If the props were meant for the composer, pass them to the `MessageComposer` you render (a thread's composer is a `MessageComposer` child of `Thread`); that turns them on, because v14 never applied them. Edits happen in that composer, so there is no separate edit composer to configure. +- Also removed from `VirtuosoContext` (the context of `additionalVirtuosoProps.components`). +- Evidence: + - `release-v14:src/components/Message/types.ts:19`; `release-v14:src/components/MessageList/MessageList.tsx:229`, `:495`; `release-v14:src/components/MessageList/VirtualizedMessageList.tsx:85`, `:202`, `:599` + - `master:src/components/Message/types.ts` (no `additionalMessageComposerProps`) + - REACT-1277 +- 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/01-message.md` +- Example needed: no + + + +### BC-106: `MessageReactions.reverse`, `MessageReactionsDetail.sort` and `MessageReactionsDetail.dialogId` are removed + +- Status: confirmed +- Area: reactions +- User impact: compile error on passing these props, and in a custom `MessageReactions` or `MessageReactionsDetail` override that reads them. In untyped code they are ignored. Nothing changes on screen: the default components ignored all three in v14 too. A custom `MessageReactions` override no longer gets `reverse={true}` from the default `MessageUI`. +- Old API: + - `MessageReactionsProps.reverse?: boolean`; the default `MessageUI` rendered `` + - `MessageReactionsDetailProps.sort?: ReactionSort` + - `MessageReactionsDetailProps.dialogId?: string`, inherited from `ReactionSelectorProps` +- Replacement: + - order of the reaction types: `sortReactions` (on `Message`, the message lists or `MessageReactions`) + - order of the reacting users in the detail: `reactionDetailsSort` (same places, and on `MessageReactionsDetail`), a `SortParamRequest[]` (BC-071) + - the detail's dialog id is `MessageReactionsDetail.getDialogId({ messageId })` +- Evidence: + - `release-v14:src/components/Reactions/MessageReactions.tsx:35`; `release-v14:src/components/Message/MessageUI.tsx:261`; `release-v14:src/components/Reactions/MessageReactionsDetail.tsx:27`, `:30` + - `master:src/components/Reactions/MessageReactions.tsx` (`MessageReactionsProps`), `master:src/components/Reactions/MessageReactionsDetail.tsx` (`Omit`) + - REACT-1277 +- Docs impact: + - `02-ui-components/08-message/12-reactions.md`, `03-ui-cookbook/04-message/02-reactions.md` +- Example needed: no + ## Message composer @@ -3426,6 +3474,73 @@ import { } from 'stream-chat'; ``` + + +### BC-107: `emojiSearchIndex` and `parent` are removed from `MessageComposer` and its context; `ComponentContext.emojiSearchIndex` is removed + +- Status: confirmed +- Area: message composer +- User impact: compile error on ``, on an `emojiSearchIndex` key in `WithComponents` overrides, and on reading `emojiSearchIndex`, `parent` or `audioRecordingConfig` from `useMessageComposerContext()`. In untyped code they are ignored. Nothing changes on screen: in v14 they only reached the composer context, and nothing read them there. Emoji autocomplete already needed the middleware below. +- Old API: + - `MessageComposerProps.emojiSearchIndex`, `MessageComposerProps.parent` + - `ComponentContextValue.emojiSearchIndex` + - `MessageComposerContextValue.emojiSearchIndex`, `.parent`, `.audioRecordingConfig` (the last was typed but never set) +- Replacement: + - emoji autocomplete: register `createTextComposerEmojiMiddleware(SearchIndex)` from `stream-chat-react/emojis` on every composer with `client.config.setSetupFunction('messageComposer', ...)`. The `EmojiSearchIndex` type is still exported. + - `parent`: the composer belongs to the thread already. Where you need the parent message, subscribe with `useStateStore(useThreadContext()?.state, selector)` and read `parentMessage`. + - `audioRecordingConfig` stays a `MessageComposer` prop; the recorder reads it, the context does not carry it. +- Evidence: + - `release-v14:src/components/MessageComposer/MessageComposer.tsx:76`, `:94`; `release-v14:src/context/ComponentContext.tsx:153`; `release-v14:src/context/MessageComposerContext.tsx:8` (`Omit`) + - `master:src/components/MessageComposer/MessageComposer.tsx` (`MessageComposerProps`); `master:src/context/MessageComposerContext.tsx` (`Omit`); `master:src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts` + - REACT-1277 +- Docs impact: + - `02-ui-components/09-message-composer/01-message_composer.md`, `02-ui-components/09-message-composer/02-message_composer_context.md`, `02-ui-components/04-channel/05-component_context.md` +- Example needed: yes + +Before (v14): + +```tsx + +``` + +After (v15): + +```tsx +import type { TextComposerMiddleware } from 'stream-chat'; +import { createTextComposerEmojiMiddleware } from 'stream-chat-react/emojis'; +import { SearchIndex } from 'emoji-mart'; + +client.config.setSetupFunction('messageComposer', ({ composer }) => { + composer.textComposer.middlewareExecutor.insert({ + middleware: [ + createTextComposerEmojiMiddleware(SearchIndex) as TextComposerMiddleware, + ], + position: { before: 'stream-io/text-composer/mentions-middleware' }, + unique: true, + }); +}); + +; +``` + + + +### BC-108: `WithDragAndDropUpload.acceptedFiles` is removed + +- Status: confirmed +- Area: message composer (drag and drop) +- User impact: compile error on ``. In untyped code it is ignored. Nothing changes: v14 ignored it too, and the drop zone accepted the file types of the composer config. +- Old API: + - `WithDragAndDropUpload` prop `acceptedFiles?: string[]` +- Replacement: set the accepted types on the composer config, `attachments.acceptedFiles` (for example in `client.config.setSetupFunction('messageComposer', ...)` or with `MessageComposer.updateConfig()`). The drop zone and the file picker (`UploadButton`) read it. +- Evidence: + - `release-v14:src/components/MessageComposer/WithDragAndDropUpload.tsx:69` (declared), `:83-99` (`accept` from `configState.attachments.acceptedFiles`) + - `master:src/components/MessageComposer/WithDragAndDropUpload.tsx` (`WithDragAndDropUpload` props) + - REACT-1277 +- Docs impact: + - `02-ui-components/09-message-composer/05-ui_components.md` +- Example needed: no + ## i18n @@ -3605,7 +3720,7 @@ const hasFr = i18n.registeredLanguages.has('fr'); - Area: message rendering (LLC-typed props) - User impact: compile errors and runtime differences in custom components and handlers that build or read these values. The React APIs are unchanged; the `stream-chat` v10 types they reference changed. - Old API -> v15 (representative): - - Reaction sort: `reactionDetailsSort` (on `Message`, `MessageList`, `VirtualizedMessageList`, `MessageContext`, `MessageReactions`, `MessageReactionsDetail`, `VirtuosoContext`), `MessageReactionsDetail.sort`, and the `sort` argument of `handleFetchReactions` / `useReactionsFetcher`: `ReactionSort` -> `SortParamRequest[]` (`[{ field: 'created_at', direction: -1 }]`). `ReactionSort` no longer exists in `stream-chat`. + - Reaction sort: `reactionDetailsSort` (on `Message`, `MessageList`, `VirtualizedMessageList`, `MessageContext`, `MessageReactions`, `MessageReactionsDetail`, `VirtuosoContext`), and the `sort` argument of `handleFetchReactions` / `useReactionsFetcher`: `ReactionSort` -> `SortParamRequest[]` (`[{ field: 'created_at', direction: -1 }]`). `ReactionSort` no longer exists in `stream-chat`. - Delete options: `MessageContext.handleDelete` / `useDeleteHandler` options: `{ deleteForMe?, hardDelete? }` -> `{ delete_for_me?, hard? }` - Mutes: `isUserMuted(message, mutes)`: `Mute[]` -> `UserMuteResponse[]` (`target` is optional) - Moderation: `isMessageBounced` / `isMessageBlocked` read only `message.moderation`, because `moderation_details` is gone from `LocalMessage` @@ -4053,49 +4168,50 @@ Every item the surface diff, the corpus, `ai-migration-v14-v15.md` and the `!` c #### diff.md "Changed", gated items -| Item | Mapping | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | -| `Channel.channel` / `ChannelProps.channel` now required | BC-019 | -| `Channel.EmptyPlaceholder` | BC-019 | -| `Channel.initializeOnMount`, `Channel.channelQueryOptions` | BC-020 | -| `Channel.activeUnreadHandler` | BC-021 | -| `Channel.markReadOnMount` | BC-022 | -| `Channel.doDeleteMessageRequest`, `doMarkReadRequest`, `doSendMessageRequest`, `doUpdateMessageRequest` | BC-023 | -| `Channel.giphyVersion`, `imageAttachmentSizeHandler`, `videoAttachmentSizeHandler`, `shouldGenerateVideoThumbnail` | BC-024 | -| `Channel.onMentionsClick`, `Channel.onMentionsHover` | BC-025 | -| `Channel.skipMessageDataMemoization` | BC-026 | -| `Channel.allowConcurrentAudioPlayback` | BC-051 | -| (all of the above as `ChannelProps.*` member-removed) | same mappings | -| `ChannelList.*` / `ChannelListProps.*` 26 removed props, `paginator` added required | BC-028 (`EmptyStateIndicator`, `Paginator`, `renderChannels` also BC-029; `getLatestMessagePreview` also BC-033; `customActiveChannel`, `setActiveChannelOnMount` also BC-013) | -| `useChannelMembershipState.*` now-required / added-required `custom` | BC-070 (LLC-typed umbrella) | -| `ChannelListItem.*` / `ChannelListItemProps.*` `channelUpdateCount`, `getLatestMessagePreview`, `key`, `setActiveChannel`, `watchers` | BC-033 | -| `ChannelListItemTimestamp.lastMessage` / `ChannelListItemTimestampProps.lastMessage` | BC-033 | -| `ChannelListItemUI.*` / `ChannelListItemUIProps.*` incl. `lastMessage`, `latestMessagePreview` | BC-033 | -| `defaultChannelListItemLabelParts` value-changed | ruled-out: i18n keys only -> BC-067 | -| `getLatestMessagePreview`, `getLatestMessagePreviewText` signature-changed | ruled-out: only `t: StreamTFunction` / `userLanguage: string` (i18n) -> BC-067 | -| `Chat.defaultLanguage`, `ChatProps.defaultLanguage`, `UseChatParams.defaultLanguage`, `UseChatParams.i18nInstance` | BC-017 | -| `useChat.channel`, `useChat.setActiveChannel` | BC-013, BC-017 | -| `useChat.latestMessageDatesByChannels` | BC-015, BC-017 | -| `useChat.translators` | BC-017 | -| `useCreateChatClient` signature-changed | BC-018 | -| `ChatViewContext.activeChatView`/`setActiveChatView` removed; `activeView`, `entityInferers`, `layoutController`, `registerViewActionSlotResolvers`, `resolveActionTargetSlot`, `setActiveView` added required (and `useChatViewContext.*`) | BC-012 | -| `InfiniteScrollPaginator` props-mass-removed, `InfiniteScrollPaginatorProps` members-mass-removed + type-params-changed | ruled-out: diff artifact. The component became polymorphic (`element?: C`, `C extends ElementType = 'div'`) and forwards `ref`; its props are `PropsWithChildren, ...>>`, so every `div` prop is still accepted and `InfiniteScrollPaginatorProps` without a type argument still means the `div` form. Own props (`contentProps`, `listenToScroll`, `loadNextDebounceMs`, `loadNextOnScrollToBottom`, `loadNextOnScrollToTop`, `threshold`, `useCapture`) are unchanged (`release-v14:src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx:17-36` vs `master:...:18-56`). Additive. | -| `NotificationApi.ensureNotificationTimeout` / `useNotificationApi.ensureNotificationTimeout` added required | ruled-out: new member on the object `useNotificationApi()` returns; only breaks code that implements `NotificationApi` by hand (mocks) | -| `Thread.*` / `ThreadProps.*` removed props, `thread` added required | BC-035 | -| `ThreadHeader.closeThread`, `ThreadHeader.thread` (and `ThreadHeaderProps.*`) | BC-036 | -| `defaultThreadListItemLabelParts` value-changed | ruled-out: i18n keys only -> BC-067 | -| `ThreadContext.*` / `ThreadContextValue.*` removed / added-required (`deactivate`, `loadNextPage`, `loadPrevPage`, `queryReplies`, `config`, `messagePaginator`, `handle*`, ...) | ruled-out: LLC (stream-chat v10). `ThreadContextValue` is `Thread | undefined`, the stream-chat class | -| `ChannelListContext.*` / `ChannelListContextValue.*` / `useChannelListContext.*` `channels`, `hasNextPage`, `loadNextPage`, `setChannels` | BC-032 | -| `ChatContext.channel` / `ChatContextValue.channel` / `useChatContext.channel` | BC-013 | -| `ChatContext.channelsQueryState` (+ value, hook) | BC-014 | -| `ChatContext.latestMessageDatesByChannels` (+ value, hook) | BC-015 | -| `ChatContext.setActiveChannel` (+ value, hook) | BC-013 | -| `ChatContext.channelManager` (+ value, hook) added required | BC-016 | -| `useChatContext` signature-changed | BC-016 | -| `ComponentContext.ChannelListUI` (+ value, hook) | BC-029 | -| `ComponentContext.HeaderStartContent` (+ value, hook) | BC-034 | -| `ComponentContext.ThreadHeader` (+ value, hook) | BC-036 | -| `ComponentContext.Message`, `useComponentContext` signature, `MessageContext.*`, `MessageComposerContext.*`, `MessageListContext.*`, `TranslationContext.*`, `MessageBounceContext` | ruled-out: other areas (message, composer, message list, i18n, contexts) | +| Item | Mapping | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | +| `Channel.channel` / `ChannelProps.channel` now required | BC-019 | +| `Channel.EmptyPlaceholder` | BC-019 | +| `Channel.initializeOnMount`, `Channel.channelQueryOptions` | BC-020 | +| `Channel.activeUnreadHandler` | BC-021 | +| `Channel.markReadOnMount` | BC-022 | +| `Channel.doDeleteMessageRequest`, `doMarkReadRequest`, `doSendMessageRequest`, `doUpdateMessageRequest` | BC-023 | +| `Channel.giphyVersion`, `imageAttachmentSizeHandler`, `videoAttachmentSizeHandler`, `shouldGenerateVideoThumbnail` | BC-024 | +| `Channel.onMentionsClick`, `Channel.onMentionsHover` | BC-025 | +| `Channel.skipMessageDataMemoization` | BC-026 | +| `Channel.allowConcurrentAudioPlayback` | BC-051 | +| (all of the above as `ChannelProps.*` member-removed) | same mappings | +| `ChannelList.*` / `ChannelListProps.*` 26 removed props, `paginator` added required | BC-028 (`EmptyStateIndicator`, `Paginator`, `renderChannels` also BC-029; `getLatestMessagePreview` also BC-033; `customActiveChannel`, `setActiveChannelOnMount` also BC-013) | +| `useChannelMembershipState.*` now-required / added-required `custom` | BC-070 (LLC-typed umbrella) | +| `ChannelListItem.*` / `ChannelListItemProps.*` `channelUpdateCount`, `getLatestMessagePreview`, `key`, `setActiveChannel`, `watchers` | BC-033 | +| `ChannelListItem.activeChannel` (+ Props) | BC-033 | +| `ChannelListItemTimestamp.lastMessage` / `ChannelListItemTimestampProps.lastMessage` | BC-033 | +| `ChannelListItemUI.*` / `ChannelListItemUIProps.*` incl. `lastMessage`, `latestMessagePreview` | BC-033 | +| `defaultChannelListItemLabelParts` value-changed | ruled-out: i18n keys only -> BC-067 | +| `getLatestMessagePreview`, `getLatestMessagePreviewText` signature-changed | ruled-out: only `t: StreamTFunction` / `userLanguage: string` (i18n) -> BC-067 | +| `Chat.defaultLanguage`, `ChatProps.defaultLanguage`, `UseChatParams.defaultLanguage`, `UseChatParams.i18nInstance` | BC-017 | +| `useChat.channel`, `useChat.setActiveChannel` | BC-013, BC-017 | +| `useChat.latestMessageDatesByChannels` | BC-015, BC-017 | +| `useChat.translators` | BC-017 | +| `useCreateChatClient` signature-changed | BC-018 | +| `ChatViewContext.activeChatView`/`setActiveChatView` removed; `activeView`, `layoutController`, `registerViewActionSlotResolvers`, `resolveActionTargetSlot`, `setActiveView` added required (and `useChatViewContext.*`) | BC-012 | +| `InfiniteScrollPaginator` props-mass-removed, `InfiniteScrollPaginatorProps` members-mass-removed + type-params-changed | ruled-out: diff artifact. The component became polymorphic (`element?: C`, `C extends ElementType = 'div'`) and forwards `ref`; its props are `PropsWithChildren, ...>>`, so every `div` prop is still accepted and `InfiniteScrollPaginatorProps` without a type argument still means the `div` form. Own props (`contentProps`, `listenToScroll`, `loadNextDebounceMs`, `loadNextOnScrollToBottom`, `loadNextOnScrollToTop`, `threshold`, `useCapture`) are unchanged (`release-v14:src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx:17-36` vs `master:...:18-56`). Additive. | +| `NotificationApi.ensureNotificationTimeout` / `useNotificationApi.ensureNotificationTimeout` added required | ruled-out: new member on the object `useNotificationApi()` returns; only breaks code that implements `NotificationApi` by hand (mocks) | +| `Thread.*` / `ThreadProps.*` removed props, `thread` added required | BC-035 | +| `ThreadHeader.closeThread`, `ThreadHeader.thread` (and `ThreadHeaderProps.*`) | BC-036 | +| `defaultThreadListItemLabelParts` value-changed | ruled-out: i18n keys only -> BC-067 | +| `ThreadContext.*` / `ThreadContextValue.*` removed / added-required (`deactivate`, `loadNextPage`, `loadPrevPage`, `queryReplies`, `config`, `messagePaginator`, `handle*`, ...) | ruled-out: LLC (stream-chat v10). `ThreadContextValue` is `Thread | undefined`, the stream-chat class | +| `ChannelListContext.*` / `ChannelListContextValue.*` / `useChannelListContext.*` `channels`, `hasNextPage`, `loadNextPage`, `setChannels` | BC-032 | +| `ChatContext.channel` / `ChatContextValue.channel` / `useChatContext.channel` | BC-013 | +| `ChatContext.channelsQueryState` (+ value, hook) | BC-014 | +| `ChatContext.latestMessageDatesByChannels` (+ value, hook) | BC-015 | +| `ChatContext.setActiveChannel` (+ value, hook) | BC-013 | +| `ChatContext.channelManager` (+ value, hook) added required | BC-016 | +| `useChatContext` signature-changed | BC-016 | +| `ComponentContext.ChannelListUI` (+ value, hook) | BC-029 | +| `ComponentContext.HeaderStartContent` (+ value, hook) | BC-034 | +| `ComponentContext.ThreadHeader` (+ value, hook) | BC-036 | +| `ComponentContext.Message`, `useComponentContext` signature, `MessageContext.*`, `MessageComposerContext.*`, `MessageListContext.*`, `TranslationContext.*`, `MessageBounceContext` | ruled-out: other areas (message, composer, message list, i18n, contexts) | Non-gated items checked and ruled out: `Chat.isMessageAIGenerated`, `SummarizedMessagePreview.latestMessage`, `UseLatestMessagePreviewParams.latestMessage`, `ChannelListItemLabelData.*`, `ThreadListItemLabelData.*`, `ChannelFileItem`/`ChannelMediaItem`/`ChannelMemberDetail`/`ChannelMembersBrowseView`/`useChannelMemberActionContext`/`useChannelMembersSearch`/`usePinnedMessagesSearch` (channel-detail plugin, LLC underlying types only), `DialogManager.state` (`StateStore` now from `@stream-io/state-store`, same shape), `EmptyStateIndicator.listType` and `LoadingIndicator.textAnchor` (union member order only), `DEFAULT_CHANNEL_LIST_ITEM_LABEL_ORDER` (union order only), `InfiniteScroll.LoadingIndicator` / `LoadMorePaginator.LoadingIndicator` (`BaseIconProps`, icons area), `Search` item types (BC-070), `AttachmentUploadedSizeIndicator` (attachments area). Modal: no API change (one line in `CloseButtonOnModalOverlay.tsx`, CSS only). @@ -4211,6 +4327,8 @@ Non-gated items checked and ruled out: `Chat.isMessageAIGenerated`, `SummarizedM - `DefaultAttachmentSelectorComponents` (value-changed) → ruled-out: same keys (`Command`, `File`, `Location`, `Poll`, `release-v14 AttachmentSelector.tsx:165` vs `master:263`); only the translation keys inside changed (BC-067) - `MessageComposer.overrideSubmitHandler`, `MessageComposerProps.overrideSubmitHandler` → BC-060 - `MessageComposer.preventClearingOnUnmount`, `MessageComposerProps.preventClearingOnUnmount` → BC-061 +- `MessageComposer.emojiSearchIndex` / `.parent`, `MessageComposerProps.emojiSearchIndex` / `.parent`, `MessageComposerContextValue.emojiSearchIndex` / `.parent` / `.audioRecordingConfig` (+ `useMessageComposerContext`), `ComponentContextValue.emojiSearchIndex` → BC-107 +- `WithDragAndDropUpload.acceptedFiles` → BC-108 - `useAttachmentManagerState.hasCustomDoUploadRequest` (removed) → BC-064 - `useAttachmentManagerState.attachmentsEnabled` / `customCdn` / `locationEnabled` / `maxNumberOfFilesPerMessage` / `pollsEnabled` (added-required) → BC-064 (additive for consumers; affects only mocks of the return type) - `useMessageComposerBindings.handleSubmit`, `UseMessageComposerBindingsParams.handleSubmit` → BC-062 @@ -4325,8 +4443,10 @@ Non-gated items checked and ruled out: `Chat.isMessageAIGenerated`, `SummarizedM - `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 + - `Message.additionalMessageComposerProps`, `MessageProps.additionalMessageComposerProps` -> BC-105 - components/MessageList - `MessageList.disableQuotedMessages`, `MessageListProps.disableQuotedMessages`, `SharedMessageProps.disableQuotedMessages` -> BC-095 + - `MessageList.additionalMessageComposerProps`, `MessageListProps.additionalMessageComposerProps`, `SharedMessageProps.additionalMessageComposerProps`, `VirtualizedMessageList.additionalMessageComposerProps` (+ Props), `VirtuosoContext.additionalMessageComposerProps` -> BC-105 - `getIsFirstUnreadMessage` -> BC-054 - `insertIntro` -> BC-056 - `MessageList.disableDateSeparator`, `MessageListProps.disableDateSeparator`, `ProcessMessagesParams.enableDateSeparator` -> BC-049 @@ -4353,6 +4473,8 @@ Non-gated items checked and ruled out: `Chat.isMessageAIGenerated`, `SummarizedM - `VirtuosoContext.Message` -> BC-042 / BC-053; `.messageActions` -> BC-041 / BC-053; `.openThread` -> BC-045 / BC-053; `.threadList` -> BC-048 / BC-053 - components/TypingIndicator - `TypingIndicator.threadList`, `TypingIndicatorProps.threadList` -> BC-048 (and BC-004) +- components/Reactions + - `MessageReactions.reverse`, `MessageReactionsProps.reverse`, `MessageReactionsDetail.sort` / `.dialogId`, `MessageReactionsDetailProps.sort` / `.dialogId` -> BC-106 - context (message items only) - `ComponentContext.Message`, `ComponentContextValue.Message`, `useComponentContext.Message` -> BC-042 - `MessageContext.actionsEnabled` / `getMessageActions` (+ `MessageContextValue`, `useMessageContext`) -> BC-041 @@ -4543,7 +4665,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. +- `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`. ## Notes For Migration Guide Drafting diff --git a/examples/tutorial/src/7-emoji-picker/App.tsx b/examples/tutorial/src/7-emoji-picker/App.tsx index 963d1bd166..78e0d2c91b 100644 --- a/examples/tutorial/src/7-emoji-picker/App.tsx +++ b/examples/tutorial/src/7-emoji-picker/App.tsx @@ -56,14 +56,14 @@ const slotRenderers: ChatViewSlotRenderers = { - + ), thread: ({ source }) => ( - + ), }; diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index db9fc3f98a..8b689bb2d6 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -688,7 +688,6 @@ const App = () => { // channels-view effect resolves after navigation (works across the threads → channels // switch; closes the covering thread only when it actually covers). MessageAlsoSentInChannelIndicator: AlsoSentInChannelIndicator, - emojiSearchIndex: SearchIndex, EmojiPicker: EmojiPickerWithCustomOptions, NotificationList: ConfigurableNotificationList, MessageReactions: CustomMessageReactions, diff --git a/src/components/ChannelListItem/ChannelListItem.tsx b/src/components/ChannelListItem/ChannelListItem.tsx index 53eb1637a3..5e5969ac04 100644 --- a/src/components/ChannelListItem/ChannelListItem.tsx +++ b/src/components/ChannelListItem/ChannelListItem.tsx @@ -45,8 +45,6 @@ export type ChannelListItemProps = { channel: Channel; /** Whether the channel is the active one. Defaults to whether it is shown in the workspace. */ active?: boolean; - /** Current selected channel object */ - activeChannel?: Channel; /** Custom class for the row button. */ className?: string; /** diff --git a/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx index 7303023fbd..f73ed057e8 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx @@ -77,7 +77,9 @@ const expectUnreadCountToBe = async ( expectedValue: string | number, ) => { await waitFor(() => { - expect(getByTestId('unread-count')).toHaveTextContent(String(expectedValue)); + expect(getByTestId('unread-count')).toHaveTextContent( + new RegExp(`^${expectedValue}$`), + ); }); }; const expectLastEventMessageToBe = async ( @@ -108,9 +110,7 @@ describe('ChannelPreview', () => { renderer( ({ - channel: props.activeChannel, client, - setActiveChannel: () => vi.fn(), })} > @@ -148,33 +148,10 @@ describe('ChannelPreview', () => { [c0, c1] = await client.queryChannelsAndHydrate({}, {}); }); - it('should mark channel as read, when set as active channel', async () => { - seedUnreadCount(c0, 10); - - const { getByTestId, rerender } = renderComponent( - { - activeChannel: c1, - channel: c0, - }, - render, - ); - await expectUnreadCountToBe(getByTestId, 10); - - renderComponent( - { - activeChannel: c0, - channel: c0, - }, - rerender, - ); - - await expectUnreadCountToBe(getByTestId, 0); - }); - // The badge subscribes to the channel's read state, so it follows along without a forced update. it('follows the channel unread count with no forced update', async () => { seedUnreadCount(c0, 100); - const { getByTestId } = renderComponent({ activeChannel: c1, channel: c0 }, render); + const { getByTestId } = renderComponent({ channel: c0 }, render); await expectUnreadCountToBe(getByTestId, 100); @@ -189,7 +166,7 @@ describe('ChannelPreview', () => { // trailing call. Without the throttle the intermediate value below would reach the badge. it('coalesces a burst of unread changes', async () => { seedUnreadCount(c0, 1); - const { getByTestId } = renderComponent({ activeChannel: c1, channel: c0 }, render); + const { getByTestId } = renderComponent({ channel: c0 }, render); await expectUnreadCountToBe(getByTestId, 1); act(() => { @@ -198,7 +175,7 @@ describe('ChannelPreview', () => { }); // Still the leading value: the burst has not been flushed yet. - expect(getByTestId('unread-count')).toHaveTextContent('1'); + expect(getByTestId('unread-count')).toHaveTextContent(/^1$/); await expectUnreadCountToBe(getByTestId, 3); }); @@ -212,7 +189,6 @@ describe('ChannelPreview', () => { const { getByTestId } = renderComponent( { - activeChannel: c0, channel: c0, }, render, @@ -237,7 +213,6 @@ describe('ChannelPreview', () => { ); const { rerender } = renderComponent( { - activeChannel: c1, channel: c0, }, render, @@ -250,7 +225,6 @@ describe('ChannelPreview', () => { renderComponent( { active: true, - activeChannel: c1, channel: c0, }, rerender, @@ -276,7 +250,6 @@ describe('ChannelPreview', () => { const { getByTestId } = renderComponent( { - activeChannel: c1, channel: c0, }, render, @@ -303,7 +276,6 @@ describe('ChannelPreview', () => { const { getByTestId } = renderComponent( { - activeChannel: c1, channel: c0, }, render, @@ -326,7 +298,6 @@ describe('ChannelPreview', () => { seedUnreadCount(c0, unreadCount); const { getByTestId } = renderComponent( { - activeChannel: c0, channel: c0, }, render, @@ -360,7 +331,6 @@ describe('ChannelPreview', () => { const { getByTestId } = renderComponent( { - activeChannel: c1, channel: c0, }, render, @@ -383,7 +353,6 @@ describe('ChannelPreview', () => { const { getByTestId } = renderComponent( { - activeChannel: c1, channel: c0, }, render, @@ -412,7 +381,6 @@ describe('ChannelPreview', () => { const { getByTestId } = renderComponent( { - activeChannel: c1, channel: c0, }, render, @@ -570,7 +538,6 @@ describe('ChannelPreview', () => { seedUnreadCount(c0, unreadCount); renderComponent( { - activeChannel: c1, channel: c0, }, render, @@ -587,7 +554,6 @@ describe('ChannelPreview', () => { seedUnreadCount(c0, unreadCount); renderComponent( { - activeChannel: c1, channel: c0, }, render, @@ -605,7 +571,6 @@ describe('ChannelPreview', () => { seedUnreadCount(c0, unreadCount); renderComponent( { - activeChannel: c1, channel: channelInPreview, }, render, @@ -619,19 +584,18 @@ describe('ChannelPreview', () => { it('should be ignored if not targeted for the current channel', async () => { const channelInPreview = c0; - const activeChannel = c1; + const otherChannel = c1; const unreadCount = getRandomInt(1, 10); seedUnreadCount(c0, unreadCount); renderComponent( { - activeChannel, channel: channelInPreview, }, render, ); await expectUnreadCountToBe(screen.getByTestId, unreadCount); await act(() => { - dispatchNotificationMarkRead({ channel: activeChannel, client, user }); + dispatchNotificationMarkRead({ channel: otherChannel, client, user }); }); await expectUnreadCountToBe(screen.getByTestId, unreadCount); }); @@ -639,7 +603,7 @@ describe('ChannelPreview', () => { it('should be ignored if originated from another user', async () => { const unreadCount = getRandomInt(1, 10); seedUnreadCount(c0, unreadCount); - renderComponent({ activeChannel: c1, channel: c0 }, render); + renderComponent({ channel: c0 }, render); await expectUnreadCountToBe(screen.getByTestId, unreadCount); await act(() => { @@ -652,7 +616,7 @@ describe('ChannelPreview', () => { it('should be ignored if only a thread was marked read', async () => { const unreadCount = getRandomInt(1, 10); seedUnreadCount(c0, unreadCount); - renderComponent({ activeChannel: c1, channel: c0 }, render); + renderComponent({ channel: c0 }, render); await expectUnreadCountToBe(screen.getByTestId, unreadCount); await act(() => { @@ -674,7 +638,7 @@ describe('ChannelPreview', () => { describe('marking a channel read with another channel still unread', () => { const renderUnreadChannelPreview = async () => { seedUnreadCount(c0, 5); - renderComponent({ activeChannel: c1, channel: c0 }, render); + renderComponent({ channel: c0 }, render); await expectUnreadCountToBe(screen.getByTestId, 5); }; @@ -750,11 +714,9 @@ describe('ChannelPreview', () => { it('should be ignored if not originated from the current user', async () => { const unreadCount = 0; const channelInPreview = c0; - const activeChannel = c1; seedUnreadCount(channelInPreview, unreadCount); renderComponent( { - activeChannel, channel: channelInPreview, }, render, @@ -774,11 +736,10 @@ describe('ChannelPreview', () => { it('should be ignored if not targeted for the current channel', async () => { const unreadCount = 0; const channelInPreview = c0; - const activeChannel = c1; + const otherChannel = c1; seedUnreadCount(channelInPreview, unreadCount); renderComponent( { - activeChannel, channel: channelInPreview, }, render, @@ -786,7 +747,7 @@ describe('ChannelPreview', () => { await expectUnreadCountToBe(screen.getByTestId, unreadCount); await act(() => { dispatchNotificationMarkUnread({ - channel: activeChannel, + channel: otherChannel, client, payload: { unread_channels: 2, unread_messages: 5 }, user, @@ -795,41 +756,13 @@ describe('ChannelPreview', () => { await expectUnreadCountToBe(screen.getByTestId, unreadCount); }); - it("should set unread count from client's unread count state for active channel", async () => { - const unreadCount = 0; - const activeChannel = c1; - // `countUnread()` is not stubbed: the event updates `channel.state.unreadCount`, and this - // test asserts reading it back. - renderComponent( - { - activeChannel, - channel: activeChannel, - }, - render, - ); - await expectUnreadCountToBe(screen.getByTestId, unreadCount); - - const eventPayload = { unread_channels: 2, unread_messages: 5 }; - await act(() => { - dispatchNotificationMarkUnread({ - channel: activeChannel, - client, - payload: { unread_channels: 2, unread_messages: 5 }, - user, - }); - }); - await expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages); - }); - - it("should set unread count from client's unread count state for non-active channel", async () => { + it("should set unread count from the channel's read state", async () => { const unreadCount = 0; const channelInPreview = c0; - const activeChannel = c1; // `countUnread()` is not stubbed: the event updates `channel.state.unreadCount`, and this // test asserts reading it back. renderComponent( { - activeChannel, channel: channelInPreview, }, render, @@ -853,7 +786,6 @@ describe('ChannelPreview', () => { it('should pass pinned=false when membership has no pinned_at', async () => { renderComponent( { - activeChannel: c1, channel: c0, }, render, @@ -871,7 +803,6 @@ describe('ChannelPreview', () => { renderComponent( { - activeChannel: c1, channel: c0, }, render, diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 91dadf58df..f776ba4c83 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -164,7 +164,6 @@ export const Message = (props: MessageProps) => { return (
- {hasReactions && } + {hasReactions && }
diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index 667be71e88..aa57302a71 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -4,7 +4,6 @@ import type { LocalMessage, SortParamRequest, UserResponse } from 'stream-chat'; import type { UserEventHandler } from './hooks'; import type { CustomMentionHandler } from './hooks/useMentionsHandler'; import type { GroupStyle } from '../MessageList/utils'; -import type { MessageComposerProps } from '../MessageComposer'; import type { ReactionsComparator } from '../Reactions/types'; import type { MessageContextValue } from '../../context/MessageContext'; import type { RenderTextFunction } from './renderText'; @@ -14,11 +13,6 @@ export type ReactEventHandler = (event: React.BaseSyntheticEvent) => Promise.`. + */ + generateRecordingTitle?: (mimeType: string) => string; // todo: what sense does hideSendButton prop make, when we have message composer actions (recording, send msg). Can we remove it? // /** Allows to hide MessageComposer's send button. */ hideSendButton?: boolean; @@ -66,8 +70,6 @@ export type MessageComposerProps = { maxRows?: number; /** Min number of rows of the textarea. While empty, it has a single row unless this is set. */ minRows?: number; - /** Parent message when composing a thread reply. */ - parent?: LocalMessage; /** * Decides whether a keydown submits the message. By default `Enter` submits unless an IME * composition is active, and `Shift`+`Enter` inserts a new line. @@ -82,12 +84,10 @@ export type MessageComposerProps = { const MessageComposerProvider = (props: PropsWithChildren) => { const messageComposerBindings = useMessageComposerBindings(props); - const { emojiSearchIndex } = useComponentContext(); const messageComposerContextValue = useCreateMessageComposerContext({ ...messageComposerBindings, ...props, - emojiSearchIndex: props.emojiSearchIndex ?? emojiSearchIndex, }); const messageComposer = useMessageComposerController(); diff --git a/src/components/MessageComposer/WithDragAndDropUpload.tsx b/src/components/MessageComposer/WithDragAndDropUpload.tsx index 49ea7eb91d..520af35e58 100644 --- a/src/components/MessageComposer/WithDragAndDropUpload.tsx +++ b/src/components/MessageComposer/WithDragAndDropUpload.tsx @@ -46,6 +46,7 @@ const attachmentManagerConfigStateSelector = (state: MessageComposerConfig) => ( /** * Wraps its children in a drop zone that uploads dropped files to the message composers inside it. + * The accepted file types come from the composer config (`attachments.acceptedFiles`). * * @example * ```tsx @@ -63,7 +64,6 @@ export const WithDragAndDropUpload = ({ component: Component = 'div', style, }: PropsWithChildren<{ - acceptedFiles?: string[]; /** * @description Element rendered as the wrapper that handles drag and drop. * @default 'div' diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerBindings.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerBindings.test.tsx new file mode 100644 index 0000000000..f11dcd210c --- /dev/null +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerBindings.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; + +import { Chat } from '../../../Chat'; +import { Channel } from '../../../Channel'; +import { initClientWithChannels } from '../../../../mock-builders'; +import { MediaRecorderMock } from '../../../../mock-builders/browser'; +import { useMessageComposerBindings } from '../useMessageComposerBindings'; +import type { MessageComposerProps } from '../../MessageComposer'; + +window.MediaRecorder = MediaRecorderMock as unknown as typeof MediaRecorder; + +const renderBindings = async (initialProps: MessageComposerProps) => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + let result!: ReturnType< + typeof renderHook, MessageComposerProps> + >; + await act(async () => { + result = await renderHook((props) => useMessageComposerBindings(props), { + initialProps, + wrapper, + }); + }); + return result; +}; + +describe('useMessageComposerBindings', () => { + it('names voice recordings with generateRecordingTitle', async () => { + const { result } = await renderBindings({ + audioRecordingEnabled: true, + generateRecordingTitle: (mimeType) => `memo.${mimeType.split('/')[1]}`, + }); + + expect( + result.current.recordingController.recorder?.generateRecordingTitle('audio/aac'), + ).toBe('memo.aac'); + }); + + it('keeps the recorder when generateRecordingTitle is a new function, and calls the latest one', async () => { + const { rerender, result } = await renderBindings({ + audioRecordingEnabled: true, + generateRecordingTitle: () => 'first', + }); + const recorder = result.current.recordingController.recorder; + + rerender({ audioRecordingEnabled: true, generateRecordingTitle: () => 'second' }); + + expect(result.current.recordingController.recorder).toBe(recorder); + expect(recorder?.generateRecordingTitle('audio/aac')).toBe('second'); + }); + + it('falls back to the default title without generateRecordingTitle', async () => { + const { result } = await renderBindings({ audioRecordingEnabled: true }); + + expect( + result.current.recordingController.recorder?.generateRecordingTitle('audio/aac'), + ).toMatch(/^audio_recording_.+\.aac$/); + }); + + it('keeps the recorder when audioRecordingConfig is a new object with the same values', async () => { + const encoder = vi.fn(); + const config = () => ({ + mediaRecorderConfig: { audioBitsPerSecond: 128000 }, + transcoderConfig: { encoder, sampleRate: 16000 }, + }); + const { rerender, result } = await renderBindings({ + audioRecordingConfig: config(), + audioRecordingEnabled: true, + }); + const recorder = result.current.recordingController.recorder; + + rerender({ audioRecordingConfig: config(), audioRecordingEnabled: true }); + + expect(result.current.recordingController.recorder).toBe(recorder); + }); + + it('creates a new recorder when the audioRecordingConfig values change', async () => { + const { rerender, result } = await renderBindings({ + audioRecordingConfig: { transcoderConfig: { sampleRate: 16000 } }, + audioRecordingEnabled: true, + }); + const recorder = result.current.recordingController.recorder; + + rerender({ + audioRecordingConfig: { transcoderConfig: { sampleRate: 44100 } }, + audioRecordingEnabled: true, + }); + + expect(result.current.recordingController.recorder).not.toBe(recorder); + expect(result.current.recordingController.recorder?.transcoderConfig.sampleRate).toBe( + 44100, + ); + }); + + it('creates a new recorder when a function in audioRecordingConfig changes identity', async () => { + const { rerender, result } = await renderBindings({ + audioRecordingConfig: { transcoderConfig: { encoder: vi.fn(), sampleRate: 16000 } }, + audioRecordingEnabled: true, + }); + const recorder = result.current.recordingController.recorder; + + rerender({ + audioRecordingConfig: { transcoderConfig: { encoder: vi.fn(), sampleRate: 16000 } }, + audioRecordingEnabled: true, + }); + + expect(result.current.recordingController.recorder).not.toBe(recorder); + }); +}); diff --git a/src/components/MessageComposer/hooks/useCreateMessageComposerContext.ts b/src/components/MessageComposer/hooks/useCreateMessageComposerContext.ts index fab6cbef16..3730ad5753 100644 --- a/src/components/MessageComposer/hooks/useCreateMessageComposerContext.ts +++ b/src/components/MessageComposer/hooks/useCreateMessageComposerContext.ts @@ -7,32 +7,26 @@ export const useCreateMessageComposerContext = (value: MessageComposerContextVal additionalTextareaProps, asyncMessagesMultiSendEnabled, audioRecordingEnabled, - emojiSearchIndex, focus, hideSendButton, maxRows, minRows, onPaste, - parent, recordingController, shouldSubmit, textareaRef, } = value; - const parentId = parent?.id; - const messageComposerContext: MessageComposerContextValue = useMemo( () => ({ additionalTextareaProps, asyncMessagesMultiSendEnabled, audioRecordingEnabled, - emojiSearchIndex, focus, hideSendButton, maxRows, minRows, onPaste, - parent, recordingController, shouldSubmit, textareaRef, @@ -41,10 +35,8 @@ export const useCreateMessageComposerContext = (value: MessageComposerContextVal [ asyncMessagesMultiSendEnabled, audioRecordingEnabled, - emojiSearchIndex, hideSendButton, minRows, - parentId, recordingController, ], ); diff --git a/src/components/MessageComposer/hooks/useMessageComposerBindings.ts b/src/components/MessageComposer/hooks/useMessageComposerBindings.ts index c85da48af9..c90c60c232 100644 --- a/src/components/MessageComposer/hooks/useMessageComposerBindings.ts +++ b/src/components/MessageComposer/hooks/useMessageComposerBindings.ts @@ -3,6 +3,8 @@ import { useTextareaRef } from './useTextareaRef'; import { usePasteHandler } from './usePasteHandler'; import type { RecordingController } from '../../MediaRecorder/hooks/useMediaRecorder'; import { useMediaRecorder } from '../../MediaRecorder/hooks/useMediaRecorder'; +import { useSameValuesReference } from '../../../utils/useSameValuesReference'; +import { useStableCallback } from '../../../utils/useStableCallback'; import type { MessageComposerProps } from '../MessageComposer'; export type UseMessageComposerBindingsParams = { @@ -14,15 +16,30 @@ export type UseMessageComposerBindingsParams = { export const useMessageComposerBindings = ( props: MessageComposerProps, ): UseMessageComposerBindingsParams => { - const { asyncMessagesMultiSendEnabled, audioRecordingConfig, audioRecordingEnabled } = - props; + const { + asyncMessagesMultiSendEnabled, + audioRecordingConfig, + audioRecordingEnabled, + generateRecordingTitle, + } = props; const { textareaRef } = useTextareaRef(props); + // A new recorder cancels the recording in progress, so inline props must not create one on + // every render: `audioRecordingConfig` does only when its values change, and + // `generateRecordingTitle` only when it is set or unset. + const recordingConfig = useSameValuesReference(audioRecordingConfig); + const stableGenerateRecordingTitle = useStableCallback( + (mimeType: string) => generateRecordingTitle?.(mimeType) ?? '', + ); + const recordingController = useMediaRecorder({ asyncMessagesMultiSendEnabled, enabled: !!audioRecordingEnabled, - recordingConfig: audioRecordingConfig, + generateRecordingTitle: generateRecordingTitle + ? stableGenerateRecordingTitle + : undefined, + recordingConfig, }); const { onPaste } = usePasteHandler(); diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index c72fcf2bf3..b885701e64 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -102,7 +102,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { } = 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, @@ -415,7 +414,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { }; type PropsDrilledToMessage = - | 'additionalMessageComposerProps' | 'closeReactionSelectorOnClick' | 'formatDate' | 'onMentionsClick' diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 5430f0d33d..39cde61de5 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -82,7 +82,6 @@ import { useLastDeliveredData } from './hooks/useLastDeliveredData'; import { useLastOwnMessage } from './hooks/useLastOwnMessage'; type PropsDrilledToMessage = - | 'additionalMessageComposerProps' | 'formatDate' | 'reactionDetailsSort' | 'renderText' @@ -199,7 +198,6 @@ const VirtualizedMessageListWithContext = ( props: VirtualizedMessageListWithContextProps, ) => { const { - additionalMessageComposerProps, additionalVirtuosoProps = {}, channel, closeReactionSelectorOnClick, @@ -230,7 +228,6 @@ const VirtualizedMessageListWithContext = ( VirtualizedMessageListProps, PropsDrilledToMessage | 'closeReactionSelectorOnClick' > = { - additionalMessageComposerProps, closeReactionSelectorOnClick, formatDate, reactionDetailsSort, diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index e0532069ad..e29992445e 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -106,7 +106,6 @@ export const messageRenderer = ( virtuosoContext: VirtuosoContext, ) => { const { - additionalMessageComposerProps, channel, closeReactionSelectorOnClick, customMessageRenderer, @@ -174,7 +173,6 @@ export const messageRenderer = (
)} ; /** Latest reactions, used for the reacting user names. Defaults to the message's latest ones. */ reactions?: ReactionResponse[]; - /** Not read by the component: the list order comes from `sortReactions`. */ - reverse?: boolean; /** * Comparator that orders the reaction types. Defaults to `sortReactions` from `MessageContext`, * then to ascending time of the first reaction. diff --git a/src/components/Reactions/MessageReactionsDetail.tsx b/src/components/Reactions/MessageReactionsDetail.tsx index 522e34b40a..67ffc3ae8b 100644 --- a/src/components/Reactions/MessageReactionsDetail.tsx +++ b/src/components/Reactions/MessageReactionsDetail.tsx @@ -24,10 +24,9 @@ export type MessageReactionsDetailProps = Partial< reactions: ReactionSummary[]; selectedReactionType: ReactionType | null; onSelectedReactionTypeChange?: (reactionType: ReactionType | null) => void; - sort?: SortParamRequest[]; totalReactionCount?: number; reactionGroups?: ReturnType['reactionGroups']; -} & ReactionSelectorProps; +} & Omit; const defaultReactionDetailsSort: SortParamRequest[] = [ { direction: -1, field: 'created_at' }, diff --git a/src/components/TextareaComposer/TextareaComposer.tsx b/src/components/TextareaComposer/TextareaComposer.tsx index c8e537871c..405938033e 100644 --- a/src/components/TextareaComposer/TextareaComposer.tsx +++ b/src/components/TextareaComposer/TextareaComposer.tsx @@ -102,6 +102,7 @@ const TextareaComposerWithLiveAnnouncements = ({ onBlur, onChange, onKeyDown, + onPaste: onPasteProp, onScroll, onSelect, placeholder: placeholderProp, @@ -301,6 +302,18 @@ const TextareaComposerWithLiveAnnouncements = ({ ], ); + // A custom `onPaste` (the prop, else `additionalTextareaProps.onPaste`) runs first; calling + // `event.preventDefault()` in it skips the default handling. + const customOnPaste = onPasteProp ?? additionalTextareaProps?.onPaste; + const pasteHandler = useCallback( + (event: React.ClipboardEvent) => { + customOnPaste?.(event); + if (event.isDefaultPrevented()) return; + onPaste(event); + }, + [customOnPaste, onPaste], + ); + const scrollHandler: UIEventHandler = useCallback( (event) => { if (onScroll) { @@ -429,6 +442,7 @@ const TextareaComposerWithLiveAnnouncements = ({ className={clsx( 'rta__textarea', 'str-chat__textarea__textarea str-chat__message-textarea', + additionalTextareaProps?.className, className, )} data-testid='message-input' @@ -440,7 +454,7 @@ const TextareaComposerWithLiveAnnouncements = ({ onCompositionEnd={onCompositionEnd} onCompositionStart={onCompositionStart} onKeyDown={keyDownHandler} - onPaste={onPaste} + onPaste={pasteHandler} onScroll={scrollHandler} onSelect={setSelection} placeholder={placeholder} diff --git a/src/components/TextareaComposer/__tests__/TextareaComposer.test.tsx b/src/components/TextareaComposer/__tests__/TextareaComposer.test.tsx index 645ecb5eba..b689d18afa 100644 --- a/src/components/TextareaComposer/__tests__/TextareaComposer.test.tsx +++ b/src/components/TextareaComposer/__tests__/TextareaComposer.test.tsx @@ -1,5 +1,5 @@ import React, { useRef } 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'; // `../../../context` must be imported before the component: they form an import cycle @@ -12,6 +12,8 @@ import { ThreadProvider } from '../../Threads/ThreadContext'; import { initClientWithChannels, mockChatContext } from '../../../mock-builders'; import type { Channel, StreamChat, Thread } from 'stream-chat'; +import type { MessageComposerContextValue } from '../../../context/MessageComposerContext'; +import type { TextareaComposerProps } from '../TextareaComposer'; const Harness = ({ channel, @@ -100,4 +102,84 @@ describe('TextareaComposer', () => { expect(document.activeElement).not.toBe(screen.getByTestId('message-input')); }); }); + + describe('additionalTextareaProps', () => { + const renderWithContext = async ( + contextValue: Partial, + props: TextareaComposerProps = {}, + ) => { + const { channel, client, threadA } = await setup(); + const textareaRef = React.createRef(); + render( + + + + + + + + + , + ); + return screen.getByTestId('message-input'); + }; + + it('adds their className next to the own className', async () => { + const textarea = await renderWithContext( + { additionalTextareaProps: { className: 'from-composer' } }, + { className: 'from-props' }, + ); + + expect(textarea).toHaveClass( + 'str-chat__message-textarea', + 'from-composer', + 'from-props', + ); + }); + + it('calls their onPaste before the default paste handling', async () => { + const calls: string[] = []; + const textarea = await renderWithContext({ + additionalTextareaProps: { onPaste: () => calls.push('custom') }, + onPaste: () => calls.push('default'), + }); + + fireEvent.paste(textarea); + + expect(calls).toEqual(['custom', 'default']); + }); + + it('skips the default paste handling when their onPaste prevents the default', async () => { + const defaultOnPaste = vi.fn(); + const textarea = await renderWithContext({ + additionalTextareaProps: { onPaste: (event) => event.preventDefault() }, + onPaste: defaultOnPaste, + }); + + fireEvent.paste(textarea); + + expect(defaultOnPaste).not.toHaveBeenCalled(); + }); + + it('prefers the own onPaste prop over theirs, and still runs the default handling', async () => { + const composerOnPaste = vi.fn(); + const defaultOnPaste = vi.fn(); + const propOnPaste = vi.fn(); + const textarea = await renderWithContext( + { + additionalTextareaProps: { onPaste: composerOnPaste }, + onPaste: defaultOnPaste, + }, + { onPaste: propOnPaste }, + ); + + fireEvent.paste(textarea); + + expect(propOnPaste).toHaveBeenCalledTimes(1); + expect(composerOnPaste).not.toHaveBeenCalled(); + expect(defaultOnPaste).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 875f72ddb1..bcfff8b63f 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -13,7 +13,6 @@ import { type ContextMenuContentProps, type ContextMenuProps, type DateSeparatorProps, - type EmojiSearchIndex, type EmptyStateIndicatorProps, type EventComponentProps, type FileDragAndDropContentProps, @@ -168,11 +167,6 @@ export type ComponentContextValue = { EditedMessagePreview?: React.ComponentType; /** Emoji picker button in the composer. No default; `stream-chat-react/emojis` exports one. */ EmojiPicker?: React.ComponentType; - /** - * Search index for the autocomplete and text replace features of the `MessageComposer`; see - * emoji-mart's headless `SearchIndex`. - */ - emojiSearchIndex?: EmojiSearchIndex; /** Shown when a message list is empty. */ EmptyStateIndicator?: React.ComponentType; /** diff --git a/src/context/MessageComposerContext.tsx b/src/context/MessageComposerContext.tsx index d1aa679323..acb1fc9adf 100644 --- a/src/context/MessageComposerContext.tsx +++ b/src/context/MessageComposerContext.tsx @@ -5,8 +5,9 @@ import type { MessageComposerProps } from '../components/MessageComposer'; import type { UseMessageComposerBindingsParams } from '../components/MessageComposer/hooks/useMessageComposerBindings'; import { requireContext } from './requireContext'; +/** The `MessageComposer` props, except those its bindings consume, plus the bindings. */ export type MessageComposerContextValue = UseMessageComposerBindingsParams & - Omit; + Omit; export const MessageComposerContext = createContext< MessageComposerContextValue | undefined diff --git a/src/plugins/SlotLayout/ChatView.tsx b/src/plugins/SlotLayout/ChatView.tsx index 142ab87cce..cbc9446f30 100644 --- a/src/plugins/SlotLayout/ChatView.tsx +++ b/src/plugins/SlotLayout/ChatView.tsx @@ -54,7 +54,6 @@ import type { SlotName, } from './layoutController/layoutControllerTypes'; import type { - ChatViewEntityBinding, ChatViewSlotFallbackProps, ChatViewSlotRenderers, LayoutDescriptor, @@ -71,12 +70,6 @@ export { defaultSlotKindRegistry } from './slotRegistry'; export type ChatView = 'channels' | 'threads'; -export type ChatViewEntityInferer = { - kind: ChatViewEntityBinding['kind']; - match: (source: unknown) => boolean; - toBinding: (source: unknown) => ChatViewEntityBinding; -}; - export type ChatViewBuiltinLayout = 'nav-rail-entity-list-workspace'; export type ChatViewProps = PropsWithChildren<{ @@ -92,7 +85,6 @@ export type ChatViewProps = PropsWithChildren<{ */ dialogManagerId?: string; duplicateEntityPolicy?: DuplicateEntityPolicy; - entityInferrers?: ChatViewEntityInferer[]; layout?: ChatViewBuiltinLayout; layoutController?: LayoutController; layouts?: LayoutDescriptor[]; @@ -137,7 +129,6 @@ export type ViewActionSlotResolvers = Partial< type ChatViewContextValue = { activeView: ChatView; - entityInferers: ChatViewEntityInferer[]; layoutController: LayoutController; registerViewActionSlotResolvers: ( view: ChatView, @@ -228,7 +219,6 @@ export const ChatView = ({ deriveWorkspaceNavigation, dialogManagerId, duplicateEntityPolicy, - entityInferrers = [], layout, layoutController, layouts: layoutsProp, @@ -319,7 +309,6 @@ export const ChatView = ({ const value = useMemo( () => ({ activeView, - entityInferers: entityInferrers, layoutController: effectiveLayoutController, registerViewActionSlotResolvers, resolveActionTargetSlot, @@ -328,7 +317,6 @@ export const ChatView = ({ [ activeView, effectiveLayoutController, - entityInferrers, registerViewActionSlotResolvers, resolveActionTargetSlot, setActiveView, diff --git a/src/utils/__tests__/useSameValuesReference.test.ts b/src/utils/__tests__/useSameValuesReference.test.ts new file mode 100644 index 0000000000..db8531768b --- /dev/null +++ b/src/utils/__tests__/useSameValuesReference.test.ts @@ -0,0 +1,88 @@ +import { renderHook } from '@testing-library/react'; + +import { useSameValuesReference } from '../useSameValuesReference'; + +const renderValue = (initialValue: T) => + renderHook(({ value }) => useSameValuesReference(value), { + initialProps: { value: initialValue }, + }); + +describe('useSameValuesReference', () => { + it('returns the first value on the first render', () => { + const value = { a: 1 }; + const { result } = renderValue(value); + + expect(result.current).toBe(value); + }); + + it('keeps the reference while new objects hold the same nested values', () => { + const { rerender, result } = renderValue({ a: { b: [1, { c: 'x' }] } }); + const first = result.current; + + rerender({ value: { a: { b: [1, { c: 'x' }] } } }); + + expect(result.current).toBe(first); + }); + + it('returns the new value when a nested value changes, and keeps that one afterwards', () => { + const { rerender, result } = renderValue({ a: { b: 1 } }); + const changed = { a: { b: 2 } }; + + rerender({ value: changed }); + expect(result.current).toBe(changed); + + rerender({ value: { a: { b: 2 } } }); + expect(result.current).toBe(changed); + }); + + it('compares arrays by length and items', () => { + const { rerender, result } = renderValue([1, 2]); + const first = result.current; + + rerender({ value: [1, 2] }); + expect(result.current).toBe(first); + + const longer = [1, 2, 3]; + rerender({ value: longer }); + expect(result.current).toBe(longer); + }); + + it('compares functions by identity', () => { + const fn = () => null; + const { rerender, result } = renderValue({ fn }); + const first = result.current; + + rerender({ value: { fn } }); + expect(result.current).toBe(first); + + const other = { fn: () => null }; + rerender({ value: other }); + expect(result.current).toBe(other); + }); + + it('compares class instances and dates by identity', () => { + const { rerender, result } = renderValue({ at: new Date(0) }); + + const sameTime = { at: new Date(0) }; + rerender({ value: sameTime }); + + expect(result.current).toBe(sameTime); + }); + + it('treats a key set to undefined as different from a missing key', () => { + const { rerender, result } = renderValue<{ a?: number }>({}); + + const withUndefined = { a: undefined }; + rerender({ value: withUndefined }); + + expect(result.current).toBe(withUndefined); + }); + + it('passes primitives through', () => { + const { rerender, result } = renderValue(1); + + rerender({ value: 'one' }); + + expect(result.current).toBe('one'); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index fdaa9a8df9..c8bf684f7f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './getTextareaCaretRect'; export * from './getWholeChar'; export * from './isDmChannel'; +export * from './useSameValuesReference'; export * from './useStableCallback'; diff --git a/src/utils/useSameValuesReference.ts b/src/utils/useSameValuesReference.ts new file mode 100644 index 0000000000..05bebf9369 --- /dev/null +++ b/src/utils/useSameValuesReference.ts @@ -0,0 +1,50 @@ +import { useLayoutEffect, useRef } from 'react'; + +const isPlainObject = (value: unknown): value is Record => { + if (typeof value !== 'object' || value === null) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Whether two values hold the same data: plain objects and arrays are compared key by key, at + * every depth, and anything else (functions, class instances, dates) by identity. + */ +const hasSameValues = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return ( + a.length === b.length && a.every((item, index) => hasSameValues(item, b[index])) + ); + } + if (!isPlainObject(a) || !isPlainObject(b)) return false; + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every( + (key) => + Object.prototype.hasOwnProperty.call(b, key) && hasSameValues(a[key], b[key]), + ) + ); +}; + +/** + * Returns the last committed value for as long as new values hold the same data, so an object or + * array literal created on every render keeps one reference. Use it to feed such a value to a + * hook dependency list, a memoized component or anything else that compares by identity. + * + * Plain objects and arrays are compared key by key, at every depth; functions, class instances + * and other values by identity, so pass stable functions inside the value. The reference advances + * only when React commits a render, so a render that React discards cannot replace it. + * + * @param value - Value whose reference should only change when its data does. + * @returns `value`, or the previously committed value when it holds the same data. + */ +export const useSameValuesReference = (value: T): T => { + const committedRef = useRef(value); + const next = hasSameValues(committedRef.current, value) ? committedRef.current : value; + useLayoutEffect(() => { + committedRef.current = next; + }); + return next; +}; From 6b7e30e5783f77c2a49cd05b10edb825d83e5eea Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Nov 2026 23:04:50 +0100 Subject: [PATCH 2/5] fix(ComponentContext)!: read override keys the SDK ignored, type keys by what they receive FileDragAndDropContent and ScrollToLatestMessageButton are read from useComponentContext() instead of rendering the defaults directly. A new MessageText key renders the message text, also while StreamedMessageText streams an AI-generated message. BaseImage, LoadingIndicator, Avatar, Timestamp, SummarizedMessagePreview, DateSeparator, Gallery and FileSizeIndicator are read from the context at the sites that rendered the defaults directly (link previews, video thumbnails, galleries, quoted messages, load-more buttons, poll vote lists, thread list items, user search results, the thread's parent message, composer previews, channel detail lists). GroupAvatar keeps the default Avatar tiles, because the Avatar slot can hold ChannelAvatar, which renders GroupAvatar. Keys are typed by the props the SDK passes them: ReactionSelector is a plain ComponentType, SearchSourceResults receives { searchSource }, MessageListItem and MessageListWrapper receive MessageListItemProps and MessageListWrapperProps, and the slots the SDK renders without props (AttachmentPreviewList, MessageComposerUI, MessageEditedIndicator, MessageStatus, NotificationAnnouncer, QuotedMessagePreview, SearchSourceResultList, TextareaComposer, VoiceRecordingPreviewSlot) take no props. Ticket: REACT-1275 BREAKING CHANGE: rendering one of those nine slots from useComponentContext() with props, or registering an override with required props, no longer compiles. Render the exported component with its props inside the override. Overrides of the keys above now also render where v14 rendered the defaults. See BC-109 in ai-docs/v15-breaking-changes.md. --- ai-docs/ai-migration-v14-v15.md | 38 +++++++- ai-docs/v15-breaking-changes.md | 51 ++++++++++- .../Attachment/LinkPreview/Card.tsx | 5 +- src/components/Attachment/ModalGallery.tsx | 3 +- .../Attachment/__tests__/Card.test.tsx | 28 +++++- src/components/Avatar/GroupAvatar.tsx | 3 +- .../Avatar/__tests__/GroupAvatar.test.tsx | 24 ++++- src/components/Gallery/GalleryUI.tsx | 4 +- .../Gallery/__tests__/GalleryUI.test.tsx | 15 ++++ .../Gallery/__tests__/ModalGallery.test.tsx | 15 ++++ src/components/LoadMore/LoadMoreButton.tsx | 5 +- .../__tests__/LoadMoreButton.test.tsx | 15 +++- .../Message/MessageBubbleContent.defaults.tsx | 7 +- .../Message/StreamedMessageText.tsx | 5 +- .../__tests__/MessageBubbleContent.test.tsx | 54 +++++++++++- .../AttachmentPreviewList.tsx | 10 ++- .../MessageComposer/LinkPreviewList.tsx | 5 +- .../MessageComposer/QuotedMessagePreview.tsx | 11 ++- .../MessageComposer/WithDragAndDropUpload.tsx | 9 +- .../__tests__/AttachmentPreviewList.test.tsx | 47 ++++++++++ .../__tests__/LinkPreviewList.test.tsx | 48 +++++++++- .../__tests__/QuotedMessagePreview.test.tsx | 51 +++++++++++ .../__tests__/WithDragAndDropUpload.test.tsx | 36 +++++++- src/components/MessageList/MessageList.tsx | 3 +- .../MessageList/VirtualizedMessageList.tsx | 3 +- .../__tests__/MessageList.test.tsx | 21 +++++ .../__tests__/VirtualizedMessageList.test.tsx | 32 ++++++- src/components/MessageList/renderMessages.tsx | 16 +++- .../Poll/PollActions/PollAnswerList.tsx | 4 +- .../PollResults/PollOptionWithVotesList.tsx | 4 +- .../__tests__/PollAnswerList.test.tsx | 55 ++++++++++++ .../PollOptionWithVotesList.test.tsx | 59 +++++++++++++ .../Search/SearchResults/SearchResultItem.tsx | 9 +- .../__tests__/SearchResultItem.test.tsx | 28 ++++++ src/components/Thread/ThreadHead.tsx | 9 +- .../Thread/__tests__/ThreadHead.test.tsx | 56 ++++++++++++ .../Threads/ThreadList/ThreadListItemUI.tsx | 9 +- .../ThreadListUnseenThreadsBanner.tsx | 4 +- .../__tests__/ThreadListItemUI.test.tsx | 57 +++++++++++- .../ThreadListUnseenThreadsBanner.test.tsx | 12 +++ src/components/VideoPlayer/VideoThumbnail.tsx | 9 +- .../__tests__/VideoThumbnail.test.tsx | 31 +++++++ src/context/ComponentContext.tsx | 87 ++++++++++++------- .../ChannelDetailListLoadingIndicator.tsx | 4 +- .../ChannelFilesView/ChannelFilesView.tsx | 7 +- .../__tests__/ChannelFilesView.test.tsx | 24 +++++ ...ChannelDetailListLoadingIndicator.test.tsx | 28 ++++++ 47 files changed, 968 insertions(+), 92 deletions(-) create mode 100644 src/components/MessageComposer/__tests__/QuotedMessagePreview.test.tsx create mode 100644 src/components/Poll/PollActions/__tests__/PollAnswerList.test.tsx create mode 100644 src/components/Poll/PollActions/__tests__/PollOptionWithVotesList.test.tsx create mode 100644 src/components/Thread/__tests__/ThreadHead.test.tsx create mode 100644 src/components/VideoPlayer/__tests__/VideoThumbnail.test.tsx create mode 100644 src/plugins/ChannelDetail/__tests__/ChannelDetailListLoadingIndicator.test.tsx diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 1afbb5ddc0..2911973541 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-108`) 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-109`) 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. @@ -145,7 +145,7 @@ code '\b(HeaderStartContent|HeaderEndContent)\b|<(Channel|Thread)Header\b' # §9 channel state and actions code '\b(ChannelStateContext|ChannelStateContextValue|ChannelStateProvider|useChannelStateContext|ChannelActionContext|ChannelActionContextValue|ChannelActionProvider|useChannelActionContext|ChannelNotifications|MarkReadWrapperOptions|RetrySendMessage|TypingContext|TypingContextValue|TypingProvider|useTypingContext|ChannelUnreadUiState|channelUnreadUiState|setChannelUnreadUiState|channelConfig|WatcherState)\b|getConfig\(|\.(read|typing|members|watcher|ownCapabilities|mutedUsers)Store\b|\.disconnected\b|\.(hasNext|hasPrev)\b' # §10 component overrides, context hook arguments, message actions -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' +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|MessageComposerUI|MessageEditedIndicator|MessageStatus|NotificationAnnouncer|QuotedMessagePreview|SearchSourceResultList|TextareaComposer)\b' # §11 message lists code '\bmessages=\{|\b(loadMore|loadMoreNewer|hasMore|hasMoreNewer|loadingMoreNewer|jumpToLatestMessage|messageLimit|highlightedMessageId|threadList|disableDateSeparator|renderMessages|RenderMessagesOptions|getIsFirstUnreadMessage|additionalVirtuosoProps|headerPosition|insertIntro|DateSeparatorMessage|useEnrichedMessages|useNewMessageNotification|useScrollLocationLogic|areMessagePropsEqual|useMessageListScrollManager|NewMessageNotification|ScrollToLatestMessageButton|InfiniteScrollPaginator|loadNextDebounceMs|loadMoreDebounceMs|internalInfiniteScrollProps|noGroupByUser|shouldGroupByUser|groupStyles|defaultItemHeight|overscan|scrollSeekPlaceHolder)\b|\bhead=\{' # §12 messages @@ -314,7 +314,8 @@ These compile cleanly, or are ignored in plain JS, and change behavior at runtim | A custom edit button shows on poll messages and giphies | `useMessagePermissions(message).canEdit` (`useUserRole` in v14) is the update permission alone | Add `!message.poll_id && message.command !== 'giphy'` ([section 10](#messageactions-and-message_actions-are-removed)) | `canEdit` | | A custom `messageActionSet` offers Edit on poll messages, or Copy on messages without text | Those rules moved from the base filter onto the default items' `isVisible` | Copy the default item's `isVisible` ([section 10](#messageactions-and-message_actions-are-removed)) | `messageActionSet`, `disableBaseMessageActionSetFilter` | | A custom `AttachmentPreviewList`, `VoiceRecordingPreviewSlot` or `LinkPreviewList` no longer shows a location, poll or empty state | Each slot mounts only while its item in `defaultMessageComposerPreviewSet` is visible | Replace the item with one whose `isVisible` covers it ([section 13](#new-composer-previews-are-a-configurable-set)) | `AttachmentPreviewList`, `VoiceRecordingPreviewSlot`, `LinkPreviewList` in `WithComponents` overrides | -| `additionalTextareaProps.className` or a textarea `onPaste` handler that did nothing in v14 now applies | v14 ignored both textarea props | Keep them if the app wants them, otherwise remove them ([section 13](#emojisearchindex-and-parent-are-removed-from-messagecomposer)) | `additionalTextareaProps`, `onPaste` | +| A `FileDragAndDropContent` override, `additionalTextareaProps.className` or a textarea `onPaste` handler that did nothing in v14 now applies | v14 rendered the default drop-zone content and ignored both textarea props | Keep them if the app wants them, otherwise remove them ([section 10](#slots-the-sdk-renders-without-props-are-typed-without-props), [section 13](#emojisearchindex-and-parent-are-removed-from-messagecomposer)) | `FileDragAndDropContent`, `additionalTextareaProps`, `onPaste` | +| A `BaseImage`, `LoadingIndicator`, `Timestamp`, `Avatar`, `DateSeparator`, `Gallery` or `FileSizeIndicator` override shows in more places | v14 rendered the default in those places (BC-109 lists them) | Keep it, or wrap that part in `WithComponents` with the exported default ([section 10](#slots-the-sdk-renders-without-props-are-typed-without-props)) | Those keys in `WithComponents` overrides | | A custom pin, delete, mute, flag or mark-unread button logs an unhandled promise rejection | The handlers reject when the request fails or the message is missing | Catch around the call ([section 12](#message-action-handlers-reject-when-they-fail)) | `handlePin`, `handleDelete`, `handleMute`, `handleFlag`, `handleMarkUnread` | | A message that quotes another and carries a poll shows the quote first; CSS on `--has-single-attachment` misses poll messages | The bubble renders `defaultMessageBubbleContentSet`; poll messages get `str-chat__message--has-poll` | Reorder with `MessageBubbleContent`, or retarget the CSS ([section 12](#new-the-message-bubbles-content-is-a-configurable-set-the-quoted-message-renders-above-a-poll)) | `has-single-attachment` (`styles`) | @@ -1345,7 +1346,7 @@ These hit apps that never passed the props, because they were v14 defaults: | `ListLoadErrorIndicator` | `{ error, isRetrying, retry }` | an icon, the list's "Couldn't load …" text and a Try again button | | `LoadingNextPageIndicator` | `{ isLoading? }` | | -`ComponentContext.LoadingIndicator` no longer affects the channel list (the `LoadingErrorIndicator` slot is removed, [section 5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state)). A first page that fails to load renders `ListLoadErrorIndicator` in place of the list; its `retry` calls `paginator.reload()`. The list keeps it on screen while the retry runs, with `isRetrying` set, so a focused retry button keeps focus, also when the retry fails; a retry that loads the list is announced. A query error that happens while the list is mounted is also reported as an error notification in the `channel-list` panel, which `ChannelNavigation` renders; a `ChannelList` used without it renders no `NotificationList` for that panel. For a fully custom list, read `useChannelPaginatorState(paginator)` and render your own. +`ComponentContext.LoadingIndicator` no longer affects the channel list (the `LoadingErrorIndicator` slot is removed, [section 5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state)), and `ComponentContext.EmptyStateIndicator` does not affect it either: override `FirstPageLoadingIndicator` and `EmptyListIndicator`. A v14 `ChannelList` `EmptyStateIndicator` prop becomes an `EmptyListIndicator` override. A first page that fails to load renders `ListLoadErrorIndicator` in place of the list; its `retry` calls `paginator.reload()`. The list keeps it on screen while the retry runs, with `isRetrying` set, so a focused retry button keeps focus, also when the retry fails; a retry that loads the list is announced. A query error that happens while the list is mounted is also reported as an error notification in the `channel-list` panel, which `ChannelNavigation` renders; a `ChannelList` used without it renders no `NotificationList` for that panel. For a fully custom list, read `useChannelPaginatorState(paginator)` and render your own. ```tsx // v14 @@ -1885,6 +1886,34 @@ TypeScript suggests `ThreadHead` for a stale `ThreadHeader` key. Do not follow i `WithComponents` merges over the parent context and merges `icons` slot by slot. `Channel`, `Thread`, the message lists and `MessageComposer` take no component overrides of their own. +### Slots the SDK renders without props are typed without props + +`AttachmentPreviewList`, `MessageComposerUI`, `MessageEditedIndicator`, `MessageStatus`, `NotificationAnnouncer`, `QuotedMessagePreview`, `SearchSourceResultList`, `TextareaComposer` and `VoiceRecordingPreviewSlot` are `React.ComponentType` with no props. The SDK rendered them without props in v14 too, so an override never received the props the v14 type listed. Your own component that renders one of these slots from `useComponentContext()` with props, and an override with required props, no longer compile. To customize a default, render the exported component with its props inside the override. `MessageComposerUI` and `TextareaComposer` read the `MessageComposer` props from `useMessageComposerContext()`: + +```tsx +// v14: a custom MessageUI passing props to the slot +const ReadIcon = () => read; +const { MessageStatus } = useComponentContext(); +; +``` + +```tsx +import { MessageList, MessageStatus, WithComponents } from 'stream-chat-react'; + +const ReadIcon = () => read; +const CustomMessageStatus = () => ; + + + +; +``` + +Not breaking: the `ReactionSelector` slot is a plain `React.ComponentType` (no `forwardRef` needed), and `SearchSourceResults` is typed with the `{ searchSource }` it receives. The new `MessageText` slot (`MessageTextProps`) renders a message's text, also an AI-generated message's while `StreamedMessageText` streams it. + +Behavior change, listed in [Breaks that do not fail to compile](#breaks-that-do-not-fail-to-compile): overrides of `FileDragAndDropContent`, `BaseImage`, `LoadingIndicator`, `Timestamp`, `Avatar`, `DateSeparator`, `Gallery` and `FileSizeIndicator` apply in places where v14 rendered the default (the drop-zone overlay, link previews, video thumbnails, galleries, quoted message thumbnails, load-more buttons, poll vote lists, the thread list's new-threads banner, thread list items, user search results, the thread's parent message date and the channel detail lists). An override registered for v14 starts rendering there too. To keep the default in one place, wrap it in `WithComponents` with the exported default, e.g. ``. [BC-109](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/v15-breaking-changes.md#bc-109) lists every place. + +`MessageListItem` and `MessageListWrapper` receive the `className` (and, for an item, `data-index`, `data-message-id` and `data-testid`) that `MessageList` passes, typed as `MessageListItemProps` and `MessageListWrapperProps`. Spread them onto the element your override renders: jump-to-message and scroll restoration find items by `data-message-id`. + ### Message UI overrides consolidate on the `MessageUI` slot `ComponentContext.Message` (deprecated in v14) and every `Message` prop that took a message UI component are removed. @@ -3632,3 +3661,4 @@ Run after every section is applied. | BC-106 | `MessageReactions.reverse`, `MessageReactionsDetail.sort` / `dialogId` removed | [12](#messagereactionsreverse-and-messagereactionsdetailsort--dialogid-are-removed) | | BC-107 | `MessageComposer` `emojiSearchIndex` / `parent` and the `emojiSearchIndex` slot removed | [13](#emojisearchindex-and-parent-are-removed-from-messagecomposer) | | BC-108 | `WithDragAndDropUpload.acceptedFiles` removed | [13](#withdraganddropuploadacceptedfiles-is-removed) | +| BC-109 | Slots typed by the props they receive; overrides apply where defaults were rendered | [10](#slots-the-sdk-renders-without-props-are-typed-without-props) | diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index da418526e5..f61f482c94 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -64,7 +64,7 @@ yarn api-surface check --doc ai-docs/v15-breaking-changes.md --diff diff.json ## Index -100 entries. +101 entries. | ID | Title | Group | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | @@ -168,6 +168,7 @@ yarn api-surface check --doc ai-docs/v15-breaking-changes.md --diff diff.json | [BC-106](#bc-106) | `MessageReactions.reverse`, `MessageReactionsDetail.sort` and `MessageReactionsDetail.dialogId` are removed | Messages and message lists | | [BC-107](#bc-107) | `emojiSearchIndex` and `parent` are removed from `MessageComposer` and its context; `ComponentContext.emojiSearchIndex` is removed | Message composer | | [BC-108](#bc-108) | `WithDragAndDropUpload.acceptedFiles` is removed | Message composer | +| [BC-109](#bc-109) | `ComponentContext` slots are typed by the props they receive, and overrides apply where defaults were rendered | Contexts and customization | ## Package and runtime @@ -633,6 +634,51 @@ export const CustomEmojiPicker = () => { - `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` - Example needed: no + + +### BC-109: `ComponentContext` slots are typed by the props they receive, and overrides apply where defaults were rendered + +- Status: confirmed +- Area: component context +- User impact: compile error where your own component renders one of these slots from `useComponentContext()` with props (``), and on an override whose props are required. Overrides that take optional props or none still type-check, and the typing changes nothing at runtime: the SDK rendered these slots without props in v14 too, so an override never received the props its type promised. Silent behavior change: overrides of several slots also render in places where v14 rendered the default (listed below). +- Old API (slot: props type in v14): + - `AttachmentPreviewList`: `AttachmentPreviewListProps` + - `MessageComposerUI`: `MessageComposerProps` + - `MessageEditedIndicator`: `MessageEditedIndicatorProps` + - `MessageStatus`: `MessageStatusProps` + - `NotificationAnnouncer`: `NotificationAnnouncerProps` + - `QuotedMessagePreview`: `QuotedMessagePreviewProps` + - `SearchSourceResultList`: `SearchSourceResultListProps` + - `TextareaComposer`: `TextareaComposerProps` + - `VoiceRecordingPreviewSlot`: `VoiceRecordingPreviewSlotProps` +- Replacement: each slot is `React.ComponentType` with no props. To customize the default, render the exported component with its props inside your override, for example `MessageStatus: () => `. `MessageComposerUI` and `TextareaComposer` read the `MessageComposer` props from `useMessageComposerContext()`. The props types are still exported. +- Evidence: + - `release-v14:src/context/ComponentContext.tsx:98`, `:102`, `:165`, `:196`, `:206`, `:232`, `:250`, `:262`, `:284` + - render sites without props: `master:src/components/MessageComposer/MessageComposerPreviews.defaults.tsx` (`AttachmentPreviewList`, `QuotedMessagePreview`, `VoiceRecordingPreviewSlot`), `master:src/components/MessageComposer/MessageComposer.tsx` (`MessageComposerUI`), `master:src/components/MessageComposer/MessageComposerUI.tsx` (`TextareaComposer`), `master:src/components/Message/MessageUI.tsx` (`MessageEditedIndicator`, `MessageStatus`), `master:src/components/Chat/Chat.tsx` (`NotificationAnnouncer`), `master:src/components/Search/SearchResults/SearchSourceResults.tsx` (`SearchSourceResultList`) + - REACT-1275 +- Docs impact: + - `02-ui-components/04-channel/05-component_context.md` +- Example needed: no (the guide section has one) + +Related, and not breaking: `ReactionSelector` is typed `React.ComponentType` instead of `React.ForwardRefExoticComponent`, so a plain function override type-checks (the SDK never passed a ref). `SearchSourceResults` is typed with the `{ searchSource }` it receives. A new `MessageText` slot renders the message text, also of an AI-generated message while `StreamedMessageText` streams it. `ScrollToLatestMessageButton` overrides apply, as in v14. + +Silent behavior change: overrides apply in places where v14 rendered the default directly, so an override registered for v14 starts rendering there too: + +- `FileDragAndDropContent`: the drop-zone overlay of `WithDragAndDropUpload` (`release-v14:src/components/MessageComposer/WithDragAndDropUpload.tsx:150`) +- `BaseImage`: link preview cards in messages (`release-v14:src/components/Attachment/LinkPreview/Card.tsx`) and in the composer (`MessageComposer/LinkPreviewList.tsx`), video thumbnails (`VideoPlayer/VideoThumbnail.tsx`), the images of `Gallery` (`Gallery/GalleryUI.tsx:250`), and the thumbnails of quoted messages in the message list and the composer and of the edited-message preview (`MessageComposer/QuotedMessagePreview.tsx`) +- `LoadingIndicator`: `LoadMoreButton` (`LoadMore/LoadMoreButton.tsx:34`), the thumbnail loading overlay of `ModalGallery` (`Attachment/ModalGallery.tsx:218`), the new-threads banner of the thread list (`Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx:48`), the poll answer and vote lists (`Poll/PollActions/PollAnswerList.tsx:76`, `PollResults/PollOptionWithVotesList.tsx:38`), and the channel detail lists (`plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx:26`) +- `Timestamp`: thread list items and user search results (`Search/SearchResults/SearchResultItem.tsx:136`) +- `Avatar`: the latest-reply avatar of thread list items (`Threads/ThreadList/ThreadListItemUI.tsx:186`) +- `DateSeparator`: the date above the thread's parent message (`Thread/ThreadHead.tsx:14`), which now also gets the list's `formatDate` +- `Gallery`: the gallery that composer attachment previews open (`MessageComposer/AttachmentPreviewList/AttachmentPreviewList.tsx:156`), which now also gets `onRequestClose` +- `FileSizeIndicator`: the channel detail files view (`plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx:92`) + +Paths are under `release-v14:src/components/`, except those that start with `plugins/`, which are under `release-v14:src/`. Thread list items also render the `SummarizedMessagePreview` slot (new in v15), so an override written for the channel list shows there too. `GroupAvatar` keeps rendering the default `Avatar` for its tiles: the `Avatar` slot can hold `ChannelAvatar`, which renders `GroupAvatar`. + +To keep the default in one of those places, wrap that part of the tree in `WithComponents` with the exported default, for example `` around a poll. + +Not breaking: `MessageListItem` is typed with `MessageListItemProps` (`className`, `data-index`, `data-message-id`, `data-testid`) and `MessageListWrapper` with `MessageListWrapperProps` (`className`), the props `MessageList` passes them. An override must spread them onto the element it renders: jump-to-message and scroll restoration find items by `data-message-id`. + ## Chat, ChatView and layout @@ -1595,7 +1641,7 @@ A single list can still be rendered on its own with ` BC-005 - `…Message` (×3) -> BC-005 - `…ThreadHeader` (×3) -> BC-005 +- `ComponentContextValue.AttachmentPreviewList` / `MessageComposerUI` / `MessageEditedIndicator` / `MessageStatus` / `NotificationAnnouncer` / `QuotedMessagePreview` / `SearchSourceResultList` / `TextareaComposer` / `VoiceRecordingPreviewSlot` props type removed -> BC-109; `ReactionSelector` (`ForwardRefExoticComponent` -> `ComponentType`) and `SearchSourceResults` (now typed with `{ searchSource }`) -> ruled-out: widened, existing overrides type-check - `useComponentContext` signature-changed -> BC-006 - `useChatContext` signature-changed -> BC-006 - `useMessageBounceContext` signature-changed -> BC-006 diff --git a/src/components/Attachment/LinkPreview/Card.tsx b/src/components/Attachment/LinkPreview/Card.tsx index 9c0f5d69a1..77e652c525 100644 --- a/src/components/Attachment/LinkPreview/Card.tsx +++ b/src/components/Attachment/LinkPreview/Card.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { BaseImage } from '../../BaseImage'; +import { BaseImage as DefaultBaseImage } from '../../BaseImage'; import { SafeAnchor } from '../../SafeAnchor'; import { useAttachmentContext } from '../../../context/AttachmentContext'; @@ -8,7 +8,7 @@ import type { RenderAttachmentProps } from '../utils'; import type { Dimensions } from '../../../types/types'; import { UnableToRenderCard } from './UnableToRenderCard'; import clsx from 'clsx'; -import { useComponentContextIcons } from '../../../context'; +import { useComponentContext, useComponentContextIcons } from '../../../context'; type CardRootProps = { cardUrl: string | undefined; @@ -42,6 +42,7 @@ type CardHeaderProps = Pick { const { dimensions, image, image_url, thumb_url, title } = props; + const { BaseImage = DefaultBaseImage } = useComponentContext(); return image ? (
{ const { IconRetry } = useComponentContextIcons(); + const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); const { t } = useTranslationContext(); const imageUrl = item.imageUrl; const [isLoadFailed, setIsLoadFailed] = useState(false); diff --git a/src/components/Attachment/__tests__/Card.test.tsx b/src/components/Attachment/__tests__/Card.test.tsx index 3e2059c0f6..c7a7a0ed5f 100644 --- a/src/components/Attachment/__tests__/Card.test.tsx +++ b/src/components/Attachment/__tests__/Card.test.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { cleanup, render, waitFor } from '@testing-library/react'; -import { Card } from '../LinkPreview/Card'; +import { Card, type CardProps } from '../LinkPreview/Card'; import { fromPartial } from '@total-typescript/shoehorn'; -import { TranslationContext } from '../../../context'; +import { TranslationContext, WithComponents } from '../../../context'; import { ChatProvider } from '../../../context/ChatContext'; import { ComponentProvider } from '../../../context/ComponentContext'; @@ -285,6 +285,30 @@ describe('Card', () => { }); }); + it('should render the BaseImage from ComponentContext in the card header', () => { + const CustomBaseImage = (props: { src?: string; title?: string }) => ( +
+ ); + const { getByTestId, queryByTestId } = render( + + + ({ + image_url: 'https://example.com/image.png', + og_scrape_url: 'https://example.com', + title: 'Card title', + })} + /> + + , + ); + + const image = getByTestId('custom-base-image'); + expect(image).toHaveAttribute('data-src', 'https://example.com/image.png'); + expect(image).toHaveAttribute('title', 'Card title'); + expect(queryByTestId('image-test')).not.toBeInTheDocument(); + }); + it('should display URL in source link if author_name is not available', async () => { const ogScrapeUrl = 'https://www.theverge.com/2020/6/15/21291288/sony-ps5-software-user-interface-ui-design-dashboard-teaser-video'; diff --git a/src/components/Avatar/GroupAvatar.tsx b/src/components/Avatar/GroupAvatar.tsx index 52716f1d44..d3aa0cc0a3 100644 --- a/src/components/Avatar/GroupAvatar.tsx +++ b/src/components/Avatar/GroupAvatar.tsx @@ -19,7 +19,8 @@ export type GroupAvatarProps = { /** * Avatar for several users: a single `Avatar` for fewer than 2 members, otherwise up to 4 avatars, - * or 2 plus a `+N` badge when there are more than 4 members. + * or 2 plus a `+N` badge when there are more than 4 members. The tiles are always the default + * `Avatar`, not the `Avatar` slot: the slot can hold `ChannelAvatar`, which renders `GroupAvatar`. */ export const GroupAvatar = ({ badgeSize, diff --git a/src/components/Avatar/__tests__/GroupAvatar.test.tsx b/src/components/Avatar/__tests__/GroupAvatar.test.tsx index 95d9d907fc..4820a153e0 100644 --- a/src/components/Avatar/__tests__/GroupAvatar.test.tsx +++ b/src/components/Avatar/__tests__/GroupAvatar.test.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { cleanup, render } from '@testing-library/react'; -import { ChannelAvatar } from '../ChannelAvatar'; +import { ChannelAvatar, type ChannelAvatarProps } from '../ChannelAvatar'; import { GroupAvatar } from '../GroupAvatar'; +import { WithComponents } from '../../../context'; afterEach(cleanup); @@ -156,6 +157,27 @@ describe('GroupAvatar', () => { 'custom-class', ); }); + + // The `Avatar` slot can hold `ChannelAvatar`, which renders `GroupAvatar`; reading the slot here + // would recurse forever. + it('renders its own Avatar tiles when the Avatar slot holds ChannelAvatar', () => { + let slotRenders = 0; + const SlotAvatar = (props: ChannelAvatarProps) => { + slotRenders += 1; + if (slotRenders > 5) + throw new Error('GroupAvatar rendered the Avatar slot recursively'); + return ; + }; + const { getAllByTestId, getByTestId } = render( + + + + , + ); + + expect(getByTestId('group-avatar')).toBeInTheDocument(); + expect(getAllByTestId('avatar')).toHaveLength(3); + }); }); describe('ChannelAvatar', () => { diff --git a/src/components/Gallery/GalleryUI.tsx b/src/components/Gallery/GalleryUI.tsx index 60e8e2ae6b..6d2ae98b9a 100644 --- a/src/components/Gallery/GalleryUI.tsx +++ b/src/components/Gallery/GalleryUI.tsx @@ -1,11 +1,12 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; -import { BaseImage } from '../BaseImage'; +import { BaseImage as DefaultBaseImage } from '../BaseImage'; import { GalleryHeader } from './GalleryHeader'; import { useGalleryContext } from './GalleryContext'; import { Button, type ButtonProps } from '../Button'; import { ModalContext, + useComponentContext, useComponentContextIcons, useTranslationContext, } from '../../context'; @@ -19,6 +20,7 @@ const TRANSITION_DURATION = 300; export const GalleryUI = () => { const { IconChevronLeft, IconChevronRight } = useComponentContextIcons(); + const { BaseImage = DefaultBaseImage } = useComponentContext(); const { t } = useTranslationContext(); const { closeOnBackgroundClick, diff --git a/src/components/Gallery/__tests__/GalleryUI.test.tsx b/src/components/Gallery/__tests__/GalleryUI.test.tsx index 098d397d02..647657eda8 100644 --- a/src/components/Gallery/__tests__/GalleryUI.test.tsx +++ b/src/components/Gallery/__tests__/GalleryUI.test.tsx @@ -112,6 +112,21 @@ describe('GalleryUI', () => { const img = screen.getByTestId('str-chat__base-image'); expect(img).toHaveAttribute('alt', 'my-fallback.png'); }); + it('should render the BaseImage from ComponentContext for the current image', () => { + const CustomBaseImage = ({ alt, src }: { alt?: string; src?: string }) => ( +
+ ); + const items = [ + makeImageItem({ alt: 'my-image.png', imageUrl: 'http://my-image.jpg' }), + ]; + + renderGalleryUI(items, { components: { BaseImage: CustomBaseImage } }); + + const image = screen.getByTestId('custom-base-image'); + expect(image).toHaveAttribute('data-src', 'http://my-image.jpg'); + expect(image).toHaveAttribute('data-alt', 'my-image.png'); + expect(screen.queryByTestId('str-chat__base-image')).not.toBeInTheDocument(); + }); }); describe('Video rendering', () => { diff --git a/src/components/Gallery/__tests__/ModalGallery.test.tsx b/src/components/Gallery/__tests__/ModalGallery.test.tsx index 30d3d95bc4..fafdc7aede 100644 --- a/src/components/Gallery/__tests__/ModalGallery.test.tsx +++ b/src/components/Gallery/__tests__/ModalGallery.test.tsx @@ -322,6 +322,21 @@ describe('ModalGallery', () => { ).not.toBeInTheDocument(); }); + it('should render the LoadingIndicator from ComponentContext in the loading overlay', () => { + const CustomLoadingIndicator = () =>
; + const items = [makeImageItem()]; + + renderComponent({ items }, { LoadingIndicator: CustomLoadingIndicator }); + + const overlay = screen.getByTestId( + 'str-chat__modal-gallery__image-loading-overlay', + ); + expect(overlay).toContainElement(screen.getByTestId('custom-loading-indicator')); + expect( + overlay.querySelector('.str-chat__loading-indicator'), + ).not.toBeInTheDocument(); + }); + it('should display image fallback on error', () => { const items = [makeImageItem(), makeImageItem()]; diff --git a/src/components/LoadMore/LoadMoreButton.tsx b/src/components/LoadMore/LoadMoreButton.tsx index 359827cf9a..4248cb626a 100644 --- a/src/components/LoadMore/LoadMoreButton.tsx +++ b/src/components/LoadMore/LoadMoreButton.tsx @@ -1,7 +1,7 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; -import { LoadingIndicator } from '../Loading'; -import { useTranslationContext } from '../../context'; +import { LoadingIndicator as DefaultLoadingIndicator } from '../Loading'; +import { useComponentContext, useTranslationContext } from '../../context'; import { Button } from '../Button'; export type LoadMoreButtonProps = { @@ -17,6 +17,7 @@ const UnMemoizedLoadMoreButton = ({ onClick, }: PropsWithChildren) => { const { t } = useTranslationContext(); + const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); const childrenOrDefaultString = children ?? t('loadMore.button.loadMore.label', 'Load more'); diff --git a/src/components/LoadMore/__tests__/LoadMoreButton.test.tsx b/src/components/LoadMore/__tests__/LoadMoreButton.test.tsx index 4cff2cb467..a6987f6596 100644 --- a/src/components/LoadMore/__tests__/LoadMoreButton.test.tsx +++ b/src/components/LoadMore/__tests__/LoadMoreButton.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'; import { LoadMoreButton } from '../LoadMoreButton'; -import { TranslationProvider } from '../../../context'; +import { TranslationProvider, WithComponents } from '../../../context'; import { mockTranslationContextValue } from '../../../mock-builders'; describe('LoadMoreButton', () => { @@ -48,6 +48,19 @@ describe('LoadMoreButton', () => { expect(loadingIndicator).toBeInTheDocument(); }); + it('should render the LoadingIndicator from ComponentContext while loading', () => { + const CustomLoadingIndicator = () =>
; + const { container, getByTestId } = render( + + null} /> + , + ); + expect(getByTestId('custom-loading-indicator')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__loading-indicator'), + ).not.toBeInTheDocument(); + }); + it('should display children', () => { const { getByText } = render( null}> diff --git a/src/components/Message/MessageBubbleContent.defaults.tsx b/src/components/Message/MessageBubbleContent.defaults.tsx index 7a23203ed0..7b212efdfa 100644 --- a/src/components/Message/MessageBubbleContent.defaults.tsx +++ b/src/components/Message/MessageBubbleContent.defaults.tsx @@ -7,7 +7,7 @@ import { useMessageContext } from '../../context/MessageContext'; import { Attachment as DefaultAttachment } from '../Attachment'; import { Poll } from '../Poll'; import type { MessageBubbleContentSetItem } from './MessageBubbleContent'; -import { MessageText } from './MessageText'; +import { MessageText as DefaultMessageText } from './MessageText'; import { QuotedMessage as DefaultQuotedMessage } from './QuotedMessage'; import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText'; @@ -38,7 +38,10 @@ const DefaultMessageBubbleContentComponents = { return ; }, Text() { - const { StreamedMessageText = DefaultStreamedMessageText } = useComponentContext(); + const { + MessageText = DefaultMessageText, + StreamedMessageText = DefaultStreamedMessageText, + } = useComponentContext(); const { isMessageAIGenerated, message, renderText } = useMessageContext(); return isMessageAIGenerated?.(message) ? ( diff --git a/src/components/Message/StreamedMessageText.tsx b/src/components/Message/StreamedMessageText.tsx index 5bf9c144fc..93265a6fd9 100644 --- a/src/components/Message/StreamedMessageText.tsx +++ b/src/components/Message/StreamedMessageText.tsx @@ -1,9 +1,9 @@ import React, { useEffect } from 'react'; import type { MessageTextProps } from './MessageText'; -import { MessageText } from './MessageText'; +import { MessageText as DefaultMessageText } from './MessageText'; -import { useChannel, useMessageContext } from '../../context'; +import { useChannel, useComponentContext, useMessageContext } from '../../context'; import { useMessageTextStreaming } from './hooks'; export type StreamedMessageTextProps = Pick< @@ -21,6 +21,7 @@ export const StreamedMessageText = (props: StreamedMessageTextProps) => { renderText, streamingLetterIntervalMs, } = props; + const { MessageText = DefaultMessageText } = useComponentContext(); const { message: messageFromContext } = useMessageContext(); const channel = useChannel(); const message = messageFromProps || messageFromContext; diff --git a/src/components/Message/__tests__/MessageBubbleContent.test.tsx b/src/components/Message/__tests__/MessageBubbleContent.test.tsx index b6a672578c..a6f7e33001 100644 --- a/src/components/Message/__tests__/MessageBubbleContent.test.tsx +++ b/src/components/Message/__tests__/MessageBubbleContent.test.tsx @@ -21,6 +21,7 @@ import { } from '../MessageBubbleContent'; import { defaultMessageBubbleContentSet } from '../MessageBubbleContent.defaults'; import type { MessageProps } from '../types'; +import type { MessageTextProps } from '../MessageText'; const user = generateUser({ id: 'me' }); @@ -68,17 +69,19 @@ const renderMessage = async ({ channel, client, components = {}, + isMessageAIGenerated, message, }: { channel: ChannelType; client: StreamChat; components?: Parameters[0]['overrides']; + isMessageAIGenerated?: (message: LocalMessage) => boolean; message: LocalMessage; }) => { let result!: ReturnType; await act(() => { result = render( - + ({ message })} /> @@ -148,6 +151,55 @@ describe('MessageBubbleContent', () => { expect(bubbleContentOrder(container)).toEqual(['text', 'custom', 'poll']); }); + it('renders the text through the MessageText override', async () => { + const { channel, client } = await setup(); + const message = generateMessage({ + text: 'just text', + user, + }) as unknown as LocalMessage; + const MessageText = vi.fn(({ message }: MessageTextProps) => ( +
{message?.text}
+ )); + + const { getByTestId } = await renderMessage({ + channel, + client, + components: { MessageText }, + message, + }); + + expect(getByTestId('custom-message-text')).toHaveTextContent('just text'); + expect(MessageText).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.objectContaining({ id: message.id }) }), + undefined, + ); + }); + + it('renders the text of an AI-generated message through the MessageText override', async () => { + const { channel, client } = await setup(); + const message = generateMessage({ + text: 'generated', + user, + }) as unknown as LocalMessage; + const MessageText = ({ message }: MessageTextProps) => ( +
{message?.text}
+ ); + const isMessageAIGenerated = vi.fn(() => true); + + const { getByTestId } = await renderMessage({ + channel, + client, + components: { MessageText }, + isMessageAIGenerated, + message, + }); + + expect(isMessageAIGenerated).toHaveBeenCalledWith( + expect.objectContaining({ id: message.id }), + ); + expect(getByTestId('custom-message-text')).toBeInTheDocument(); + }); + it('has no accessibility violations', async () => { const { container } = await renderMessage(await setup()); diff --git a/src/components/MessageComposer/AttachmentPreviewList/AttachmentPreviewList.tsx b/src/components/MessageComposer/AttachmentPreviewList/AttachmentPreviewList.tsx index 008125f1bb..f9deb0e9b8 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/AttachmentPreviewList.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/AttachmentPreviewList.tsx @@ -25,7 +25,7 @@ import { type MediaAttachmentPreviewProps, } from './MediaAttachmentPreview'; import { toBaseImageDescriptors } from '../../BaseImage'; -import { Gallery } from '../../Gallery'; +import { Gallery as DefaultGallery } from '../../Gallery'; import { GlobalModal, type ModalCloseSource } from '../../Modal'; import { useComponentContext } from '../../../context'; import { useRetryAttachmentUpload } from './useRetryAttachmentUpload'; @@ -49,7 +49,7 @@ export const AttachmentPreviewList = ({ }: AttachmentPreviewListProps) => { const messageComposer = useMessageComposerController(); const retryUpload = useRetryAttachmentUpload(); - const { Modal = GlobalModal } = useComponentContext(); + const { Gallery = DefaultGallery, Modal = GlobalModal } = useComponentContext(); const [showPreview, setShowPreview] = useState(false); const initialIndexRef = useRef(0); const preventOverlayClose = useCallback( @@ -155,7 +155,11 @@ export const AttachmentPreviewList = ({ onCloseAttempt={preventOverlayClose} open={showPreview} > - + setShowPreview(false)} + /> )}
diff --git a/src/components/MessageComposer/LinkPreviewList.tsx b/src/components/MessageComposer/LinkPreviewList.tsx index d8a463846b..8dbd0104e0 100644 --- a/src/components/MessageComposer/LinkPreviewList.tsx +++ b/src/components/MessageComposer/LinkPreviewList.tsx @@ -6,9 +6,9 @@ import { useStateStore } from '../../store'; import { PopperTooltip } from '../Tooltip'; import { useEnterLeaveHandlers } from '../Tooltip/hooks'; import { useMessageComposerController } from './hooks'; -import { BaseImage } from '../BaseImage'; +import { BaseImage as DefaultBaseImage } from '../BaseImage'; import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; -import { useComponentContextIcons } from '../../context'; +import { useComponentContext, useComponentContextIcons } from '../../context'; export type LinkPreviewListProps = { displayLinkCount?: number; @@ -47,6 +47,7 @@ type LinkPreviewProps = { export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { const { IconLink } = useComponentContextIcons(); + const { BaseImage = DefaultBaseImage } = useComponentContext(); const { linkPreviewsManager } = useMessageComposerController(); const { handleEnter, handleLeave, tooltipVisible } = useEnterLeaveHandlers(); diff --git a/src/components/MessageComposer/QuotedMessagePreview.tsx b/src/components/MessageComposer/QuotedMessagePreview.tsx index faf38841f3..a596fac83d 100644 --- a/src/components/MessageComposer/QuotedMessagePreview.tsx +++ b/src/components/MessageComposer/QuotedMessagePreview.tsx @@ -36,12 +36,12 @@ import { useAttachmentContext } from '../../context/AttachmentContext'; import type { MessageContextValue } from '../../context'; import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; import clsx from 'clsx'; -import { BaseImage } from '../BaseImage'; +import { type BaseImageProps, BaseImage as DefaultBaseImage } from '../BaseImage'; import { FileIcon } from '../FileIcon'; import { QuotedMessageIndicator } from './QuotedMessageIndicator'; import { getRenderTextMentionEntities } from '../Message/renderText/rehypePlugins'; import { isDeletedMessage } from '../MessageList'; -import { useComponentContextIcons } from '../../context'; +import { useComponentContext, useComponentContextIcons } from '../../context'; import type { IconSlots } from '../Icons/slots'; const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ @@ -165,9 +165,10 @@ type PreviewType = const getAttachmentIconWithType = ( quotedMessage: LocalMessage | MessageResponse | null, giphyVersionName: GiphyVersions, - // Passed in rather than read from the hook: this plain helper runs inside a `useMemo`, where + // Passed in rather than read from the hooks: this plain helper runs inside a `useMemo`, where // hooks cannot be called. icons: Required, + BaseImage: ComponentType, ): { groupedAttachments: GroupedAttachments; Icon: ComponentType; @@ -345,6 +346,7 @@ export const QuotedMessagePreviewUI = ({ const { t, userLanguage } = useTranslationContext(); const { giphyVersion: giphyVersionName = 'fixed_height' } = useAttachmentContext(); const icons = useComponentContextIcons(); + const { BaseImage = DefaultBaseImage } = useComponentContext(); const quotedMessageText = useMemo( () => @@ -378,7 +380,7 @@ export const QuotedMessagePreviewUI = ({ Icon: AttachmentIcon, PreviewImage, previewType, - } = getAttachmentIconWithType(quotedMessage, giphyVersionName, icons); + } = getAttachmentIconWithType(quotedMessage, giphyVersionName, icons, BaseImage); let renderedText: ReactNode | undefined; @@ -447,6 +449,7 @@ export const QuotedMessagePreviewUI = ({ renderedText, }; }, [ + BaseImage, giphyVersionName, icons, quotedMessage, diff --git a/src/components/MessageComposer/WithDragAndDropUpload.tsx b/src/components/MessageComposer/WithDragAndDropUpload.tsx index 520af35e58..871148f081 100644 --- a/src/components/MessageComposer/WithDragAndDropUpload.tsx +++ b/src/components/MessageComposer/WithDragAndDropUpload.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import type { MessageComposerConfig } from 'stream-chat'; import { + useComponentContext, useComponentContextIcons, useIsWithinMessageComposerContext, useTranslationContext, @@ -46,7 +47,8 @@ const attachmentManagerConfigStateSelector = (state: MessageComposerConfig) => ( /** * Wraps its children in a drop zone that uploads dropped files to the message composers inside it. - * The accepted file types come from the composer config (`attachments.acceptedFiles`). + * The accepted file types come from the composer config (`attachments.acceptedFiles`), and the + * overlay shown while dragging is the `FileDragAndDropContent` slot. * * @example * ```tsx @@ -73,6 +75,9 @@ export const WithDragAndDropUpload = ({ style?: CSSProperties; }>) => { const dropHandlersRef = useRef void>>(new Set()); + const { + FileDragAndDropContent: FileDragAndDropContentComponent = FileDragAndDropContent, + } = useComponentContext(); const dragAndDropUploadContext = useDragAndDropUploadContext(); const messageComposer = useMessageComposerController(); const { isUploadEnabled } = useAttachmentManagerState(); @@ -147,7 +152,7 @@ export const WithDragAndDropUpload = ({ })} role='presentation' > - +
)} {children} diff --git a/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx b/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx index 9aeed65712..cb07a7c0e4 100644 --- a/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx +++ b/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx @@ -516,6 +516,53 @@ describe('AttachmentPreviewList', () => { expect(screen.getByRole('img', { name: 'image-upload-1' })).toBeInTheDocument(); }); + it('renders the Gallery from ComponentContext in the preview modal', async () => { + const Gallery = vi.fn<(props: { onRequestClose?: () => void }) => React.ReactElement>( + () =>
, + ); + + await renderComponent({ + attachments: [ + generateLocalImageUploadAttachmentData( + { id: 'image-upload-1', uploadState: 'finished' }, + { + fallback: 'image-upload-1', + image_url: 'https://example.com/image-upload-1.jpg', + }, + ), + generateLocalImageUploadAttachmentData( + { id: 'image-upload-2', uploadState: 'finished' }, + { + fallback: 'image-upload-2', + image_url: 'https://example.com/image-upload-2.jpg', + }, + ), + ], + components: { Gallery }, + }); + + fireEvent.click(screen.getAllByTestId('attachment-preview-media')[1]); + + expect(screen.getByTestId('custom-gallery')).toBeInTheDocument(); + expect(Gallery).toHaveBeenLastCalledWith( + expect.objectContaining({ + initialIndex: 1, + items: [ + expect.objectContaining({ imageUrl: 'https://example.com/image-upload-1.jpg' }), + expect.objectContaining({ imageUrl: 'https://example.com/image-upload-2.jpg' }), + ], + onRequestClose: expect.any(Function), + }), + undefined, + ); + + // A custom Gallery closes the preview through `onRequestClose`. + act(() => { + Gallery.mock.lastCall?.[0].onRequestClose?.(); + }); + expect(screen.queryByTestId('custom-gallery')).not.toBeInTheDocument(); + }); + it('passes onCloseAttempt to prevent overlay-close handling for gallery preview modal', async () => { const modalProps: Array = []; const Modal = ({ children, open, ...props }: any) => { diff --git a/src/components/MessageComposer/__tests__/LinkPreviewList.test.tsx b/src/components/MessageComposer/__tests__/LinkPreviewList.test.tsx index 76e7cdff5a..7395cd3ab0 100644 --- a/src/components/MessageComposer/__tests__/LinkPreviewList.test.tsx +++ b/src/components/MessageComposer/__tests__/LinkPreviewList.test.tsx @@ -18,9 +18,15 @@ import React from 'react'; import { fromPartial } from '@total-typescript/shoehorn'; import { Chat } from '../../Chat'; import { Channel } from '../../Channel'; +import { WithComponents } from '../../../context'; import type { ChannelProps } from '../../Channel'; -import type { Attachment, StreamChat } from 'stream-chat'; +import type { + Attachment, + LinkPreview, + Channel as StreamChannel, + StreamChat, +} from 'stream-chat'; import { LinkPreviewStatus } from 'stream-chat'; import type { GenerateChannelOptions } from '../../../mock-builders/generator/channel'; import { LinkPreviewCard, LinkPreviewList } from '../LinkPreviewList'; @@ -145,13 +151,28 @@ describe('LinkPreviewList', () => { }); }); -const renderLinkPreviewCard = async ({ channel, client, linkPreview }) => { +const renderLinkPreviewCard = async ({ + channel, + client, + components, + linkPreview, +}: { + channel: StreamChannel; + client: StreamChat; + components?: React.ComponentProps['overrides']; + linkPreview: Attachment | LinkPreview; +}) => { let result: RenderResult; + const card = ; await act(() => { result = render( - + {components ? ( + {card} + ) : ( + card + )} , ); @@ -282,6 +303,27 @@ describe('LinPreviewCard', () => { 'https://example.com/thumb.png', ); }); + it('renders the BaseImage from ComponentContext as the thumbnail', async () => { + const CustomBaseImage = ({ src, title }: { src?: string; title?: string }) => ( +
+ ); + const { channel, client } = await setup(); + await renderLinkPreviewCard({ + channel, + client, + components: { BaseImage: CustomBaseImage }, + linkPreview: fromPartial({ + image_url: 'https://example.com/image.png', + og_scrape_url: 'https://example.com', + status: LinkPreviewStatus.LOADED, + title: 'title', + }), + }); + const image = screen.getByTestId('custom-base-image'); + expect(image).toHaveAttribute('data-src', 'https://example.com/image.png'); + expect(image).toHaveAttribute('title', 'title'); + expect(screen.queryByTestId('str-chat__base-image')).not.toBeInTheDocument(); + }); it('renders no thumbnail when the preview has only a title_link', async () => { const { channel, client } = await setup(); await renderLinkPreviewCard({ diff --git a/src/components/MessageComposer/__tests__/QuotedMessagePreview.test.tsx b/src/components/MessageComposer/__tests__/QuotedMessagePreview.test.tsx new file mode 100644 index 0000000000..a25ad5e97e --- /dev/null +++ b/src/components/MessageComposer/__tests__/QuotedMessagePreview.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { StreamChat } from 'stream-chat'; + +import { ChatProvider, TranslationProvider, WithComponents } from '../../../context'; +import { + generateImageAttachment, + generateMessage, + getTestClientWithUser, + mockChatContext, + mockTranslationContextValue, +} from '../../../mock-builders'; +import { QuotedMessagePreviewUI } from '../QuotedMessagePreview'; + +let client: StreamChat; + +beforeAll(async () => { + client = await getTestClientWithUser({ id: 'current-user' }); +}); + +describe('QuotedMessagePreviewUI', () => { + it('renders the BaseImage from ComponentContext for an image quoted message', () => { + const CustomBaseImage = ({ alt, src }: { alt?: string; src?: string }) => ( +
+ ); + const quotedMessage = generateMessage({ + attachments: [ + generateImageAttachment({ + fallback: 'photo.png', + image_url: 'https://example.com/photo.png', + }), + ], + text: '', + }); + + render( + + + + + + + , + ); + + const image = screen.getByTestId('custom-base-image'); + expect(image).toHaveAttribute('data-src', 'https://example.com/photo.png'); + expect(image).toHaveAttribute('data-alt', 'photo.png'); + expect(screen.queryByTestId('str-chat__base-image')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/MessageComposer/__tests__/WithDragAndDropUpload.test.tsx b/src/components/MessageComposer/__tests__/WithDragAndDropUpload.test.tsx index 44e16ff9c0..a6441e72ed 100644 --- a/src/components/MessageComposer/__tests__/WithDragAndDropUpload.test.tsx +++ b/src/components/MessageComposer/__tests__/WithDragAndDropUpload.test.tsx @@ -6,7 +6,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { Channel } from '../../Channel/Channel'; -import { MessageComposerContextProvider } from '../../../context'; +import { MessageComposerContextProvider, WithComponents } from '../../../context'; import { Chat } from '../../Chat'; import type { MessageComposerContextValue } from '../../../context'; import { @@ -125,4 +125,38 @@ describe('WithDragAndDropUpload', () => { expect(nested).toHaveClass('nested'); expect(nested).toHaveStyle({ color: 'rgb(255, 0, 0)' }); }); + + it('renders the FileDragAndDropContent override while files are dragged over it', async () => { + const { channel, client } = await setup(); + const CustomDropContent = ({ isDragRejected }: { isDragRejected: boolean }) => ( +
{String(isDragRejected)}
+ ); + + await act(async () => { + await render( + + + + + + + + + , + ); + }); + + // react-dropzone reads the dragged files asynchronously. + await act(async () => { + await fireEvent.dragEnter(screen.getByTestId('subscriber').parentElement!, { + dataTransfer: { + files: [file()], + items: [{ kind: 'file', type: 'text/plain' }], + types: ['Files'], + }, + }); + }); + + expect(await screen.findByTestId('custom-drop-content')).toHaveTextContent('false'); + }); }); diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index b885701e64..3567d62988 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -50,7 +50,7 @@ import { useStateStore } from '../../store'; import type { InfiniteScrollPaginatorProps } from '../InfiniteScrollPaginator/InfiniteScrollPaginator'; import { InfiniteScrollPaginator } from '../InfiniteScrollPaginator/InfiniteScrollPaginator'; import { useMessagePaginator } from '../../hooks'; -import { ScrollToLatestMessageButton } from './ScrollToLatestMessageButton'; +import { ScrollToLatestMessageButton as DefaultScrollToLatestMessageButton } from './ScrollToLatestMessageButton'; import { useCanPaginateReplies } from './hooks/useCanPaginateReplies'; type MessageListWithContextProps = MessageListProps; @@ -130,6 +130,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { LoadingIndicator = DefaultLoadingIndicator, MessageListWrapper = 'ul', NewMessageNotification = DefaultNewMessageNotification, + ScrollToLatestMessageButton = DefaultScrollToLatestMessageButton, TypingIndicator = DefaultTypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, } = useComponentContext(); diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 39cde61de5..57725d53ed 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -46,8 +46,8 @@ import { } from './VirtualizedMessageListComponents'; import { + ScrollToLatestMessageButton as DefaultScrollToLatestMessageButton, UnreadMessagesSeparator as DefaultUnreadMessagesSeparator, - ScrollToLatestMessageButton, } from '../MessageList'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; @@ -253,6 +253,7 @@ const VirtualizedMessageListWithContext = ( GiphyPreviewMessage = DefaultGiphyPreviewMessage, MessageSystem = DefaultMessageSystem, NewMessageNotification = DefaultNewMessageNotification, + ScrollToLatestMessageButton = DefaultScrollToLatestMessageButton, TypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, UnreadMessagesSeparator = DefaultUnreadMessagesSeparator, diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index d9c121cf10..ae23573c6e 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -463,6 +463,27 @@ describe('MessageList', () => { }); }); + it('should render the ScrollToLatestMessageButton override with the list state', async () => { + const ScrollToLatestMessageButton = vi.fn(() => ( +
+ )); + renderComponent({ + channelProps: { channel }, + chatClient, + components: { ScrollToLatestMessageButton }, + msgListProps: { messages: [message1] }, + }); + + expect(await screen.findByTestId('custom-scroll-to-latest')).toBeInTheDocument(); + expect(ScrollToLatestMessageButton).toHaveBeenCalledWith( + expect.objectContaining({ + isNotAtLatestMessageSet: false, + onClick: expect.any(Function), + }), + undefined, + ); + }); + it('should render EmptyStateIndicator with corresponding list type in main message list', async () => { renderComponent({ channelProps: { channel }, diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index b76790ddb1..55a34fd1c0 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -26,6 +26,8 @@ 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 { ComponentContextValue } from '../../../context'; +import type { VirtualizedMessageListProps } from '../VirtualizedMessageList'; const threadWithParentMessage = () => fromPartial({ @@ -264,7 +266,15 @@ describe('VirtualizedMessageList', () => { return null; }; - const renderList = async ({ hasMoreHead = false, listProps = {} } = {}) => { + const renderList = async ({ + hasMoreHead = false, + listProps = {}, + overrides = {}, + }: { + hasMoreHead?: boolean; + listProps?: Partial; + overrides?: Partial; + } = {}) => { const user1 = generateUser(); const user2 = generateUser(); // The file-wide nanoid mock collapses generated IDs, which would dedupe the messages down @@ -289,7 +299,9 @@ describe('VirtualizedMessageList', () => { render( - + @@ -304,6 +316,22 @@ describe('VirtualizedMessageList', () => { scrollToBottom = undefined; }); + it('renders the ScrollToLatestMessageButton override with the list state', async () => { + const ScrollToLatestMessageButton = vi.fn(() => ( +
+ )); + await renderList({ overrides: { ScrollToLatestMessageButton } }); + + expect(screen.getByTestId('custom-scroll-to-latest')).toBeInTheDocument(); + expect(ScrollToLatestMessageButton).toHaveBeenCalledWith( + expect.objectContaining({ + isNotAtLatestMessageSet: false, + onClick: expect.any(Function), + }), + undefined, + ); + }); + it('anchors the initial scroll to the end of the last item', async () => { const props = await renderList(); diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx index dd4420928d..d16e7c677b 100644 --- a/src/components/MessageList/renderMessages.tsx +++ b/src/components/MessageList/renderMessages.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { PropsWithChildren, ReactNode } from 'react'; import React, { Fragment } from 'react'; import type { GroupStyle, RenderedMessage } from './utils'; import { getIsFirstUnreadMessage, isDateSeparatorMessage, isIntroMessage } from './utils'; @@ -42,6 +42,20 @@ export type SharedMessageProps = Omit; export type MessageRenderer = (options: RenderMessagesOptions) => Array; +/** + * What `MessageList` passes to each `MessageListItem`. Spread them onto the rendered element: + * jump-to-message and scroll restoration find items by `data-message-id`. + */ +export type MessageListItemProps = PropsWithChildren<{ + className?: string; + 'data-index'?: number; + 'data-message-id'?: string; + 'data-testid'?: string; +}>; + +/** What `MessageList` passes to its `MessageListWrapper`. */ +export type MessageListWrapperProps = PropsWithChildren<{ className?: string }>; + type MessagePropsToOmit = | 'channel' | 'deliveredTo' diff --git a/src/components/Poll/PollActions/PollAnswerList.tsx b/src/components/Poll/PollActions/PollAnswerList.tsx index 12a645deea..f94dd130e2 100644 --- a/src/components/Poll/PollActions/PollAnswerList.tsx +++ b/src/components/Poll/PollActions/PollAnswerList.tsx @@ -4,10 +4,11 @@ import { Viewer } from '../../Dialog'; import { PollVote } from '../PollVote'; import { usePollAnswerPagination } from '../hooks'; import { InfiniteScrollPaginator } from '../../InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { LoadingIndicator } from '../../Loading'; +import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading'; import { useStateStore } from '../../../store'; import { useChatContext, + useComponentContext, useModalContext, usePollContext, useTranslationContext, @@ -30,6 +31,7 @@ export type PollAnswerListProps = { export const PollAnswerList = ({ onUpdateOwnAnswerClick }: PollAnswerListProps) => { const { client } = useChatContext(); + const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); const { t } = useTranslationContext(); const { poll } = usePollContext(); const { close } = useModalContext(); diff --git a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesList.tsx b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesList.tsx index 0be7453ade..c82eedec98 100644 --- a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesList.tsx +++ b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesList.tsx @@ -1,7 +1,8 @@ import React, { useMemo } from 'react'; import { PollVoteListing } from '../../PollVote'; import { usePollOptionVotesPagination } from '../../hooks'; -import { LoadingIndicator } from '../../../Loading'; +import { LoadingIndicator as DefaultLoadingIndicator } from '../../../Loading'; +import { useComponentContext } from '../../../../context'; import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; import type { PollOptionResponseData, PollOptionVotesQueryParams } from 'stream-chat'; import { PollOptionWithVotesHeader } from './PollOptionWithVotesHeader'; @@ -15,6 +16,7 @@ export const PollOptionWithVotesList = ({ option, optionOrderNumber, }: PollOptionWithVotesListProps) => { + const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); const paginationParams = useMemo( () => ({ filter: { option_id: option.id } }), [option.id], diff --git a/src/components/Poll/PollActions/__tests__/PollAnswerList.test.tsx b/src/components/Poll/PollActions/__tests__/PollAnswerList.test.tsx new file mode 100644 index 0000000000..d4074aa3f9 --- /dev/null +++ b/src/components/Poll/PollActions/__tests__/PollAnswerList.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Poll } from 'stream-chat'; + +import { PollAnswerList } from '../PollAnswerList'; +import { + ChatProvider, + PollProvider, + TranslationProvider, + WithComponents, +} from '../../../../context'; +import { + generatePoll, + generateUser, + getTestClientWithUser, + mockChatContext, + mockTranslationContextValue, +} from '../../../../mock-builders'; +import { mockT } from '../../../../mock-builders/translator'; + +vi.mock('../../hooks', async (importOriginal) => ({ + ...(await importOriginal()), + usePollAnswerPagination: () => ({ + answers: [], + hasNextPage: true, + loading: true, + loadMore: vi.fn(), + }), +})); + +describe('PollAnswerList', () => { + it('renders the LoadingIndicator from ComponentContext while the next page loads', async () => { + const user = generateUser(); + const client = await getTestClientWithUser(user); + const poll = new Poll({ client, poll: generatePoll() }); + const CustomLoadingIndicator = () =>
; + + const { container } = render( + + + + + + + + + , + ); + + expect(screen.getByTestId('custom-loading-indicator')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__loading-indicator'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Poll/PollActions/__tests__/PollOptionWithVotesList.test.tsx b/src/components/Poll/PollActions/__tests__/PollOptionWithVotesList.test.tsx new file mode 100644 index 0000000000..57b2e28693 --- /dev/null +++ b/src/components/Poll/PollActions/__tests__/PollOptionWithVotesList.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Poll } from 'stream-chat'; + +import { PollOptionWithVotesList } from '../PollResults/PollOptionWithVotesList'; +import { + ChatProvider, + PollProvider, + TranslationProvider, + WithComponents, +} from '../../../../context'; +import { + generatePoll, + generateUser, + getTestClientWithUser, + mockChatContext, + mockTranslationContextValue, +} from '../../../../mock-builders'; +import { mockT } from '../../../../mock-builders/translator'; + +vi.mock('../../hooks', async (importOriginal) => ({ + ...(await importOriginal()), + usePollOptionVotesPagination: () => ({ + hasNextPage: true, + loading: true, + loadMore: vi.fn(), + votes: [], + }), +})); + +describe('PollOptionWithVotesList', () => { + it('renders the LoadingIndicator from ComponentContext while the next page loads', async () => { + const user = generateUser(); + const client = await getTestClientWithUser(user); + const pollData = generatePoll(); + const poll = new Poll({ client, poll: pollData }); + const CustomLoadingIndicator = () =>
; + + const { container } = render( + + + + + + + + + , + ); + + expect(screen.getByTestId('custom-loading-indicator')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__loading-indicator'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx index 0aadb27cb5..607b28052a 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -22,7 +22,7 @@ import { useWorkspaceNavigation, } from '../../../context'; import type { TranslationContextValue } from '../../../context'; -import { Timestamp } from '../../../components/Message/Timestamp'; +import { Timestamp as DefaultTimestamp } from '../../../components/Message/Timestamp'; import { useStateStore } from '../../../store'; type SearchResultMessage = MessageResponse & { channel?: ChannelResponse }; @@ -199,8 +199,11 @@ export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemPro const { openChannel } = useWorkspaceNavigation(); const { directMessagingChannelType } = useSearchContext(); const { t } = useTranslationContext(); - const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } = - useComponentContext(); + const { + Avatar = DefaultAvatar, + extractDisplayInfo = defaultExtractDisplayInfo, + Timestamp = DefaultTimestamp, + } = useComponentContext(); const onClick = useCallback( (event: React.MouseEvent) => { diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx index acfc34826b..4b4050448d 100644 --- a/src/components/Search/__tests__/SearchResultItem.test.tsx +++ b/src/components/Search/__tests__/SearchResultItem.test.tsx @@ -16,6 +16,7 @@ import { ChatProvider, DialogManagerProvider, TranslationProvider, + WithComponents, } from '../../../context'; import { generateChannel, @@ -25,6 +26,7 @@ import { initClientWithChannels, mockTranslationContextValue, } from '../../../mock-builders'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; import { mockT } from '../../../mock-builders/translator'; const CHANNEL_PREVIEW_BUTTON_TEST_ID = 'channel-list-item-button'; @@ -398,6 +400,32 @@ describe('SearchResultItem Components', () => { expect(screen.getByText(String(user.name))).toBeInTheDocument(); }); + it('renders the Timestamp from ComponentContext with the last active date', async () => { + const lastActive = new Date('2026-01-02T03:04:05.000Z'); + const CustomTimestamp = vi.fn(() => ); + const UserSearchResultItemWithTimestamp = ( + props: React.ComponentProps, + ) => ( + + + + ); + + await renderComponent({ + SearchResultItemComponent: UserSearchResultItemWithTimestamp, + userData: { ...user, last_active: convertDateToTimestamp(lastActive) }, + }); + + expect(screen.getByTestId('custom-timestamp')).toBeInTheDocument(); + expect(CustomTimestamp).toHaveBeenCalledWith( + expect.objectContaining({ + customClass: 'str-chat__search-result__last-active-timestamp', + timestamp: lastActive, + }), + undefined, + ); + }); + it('handles user selection', async () => { await renderComponent({ SearchResultItemComponent, userData: user }); diff --git a/src/components/Thread/ThreadHead.tsx b/src/components/Thread/ThreadHead.tsx index 8e2d60a984..a7c3071ba4 100644 --- a/src/components/Thread/ThreadHead.tsx +++ b/src/components/Thread/ThreadHead.tsx @@ -6,14 +6,17 @@ import { Message } from '../Message'; import { ThreadStart as DefaultThreadStart } from './ThreadStart'; import { useComponentContext } from '../../context'; -import { DateSeparator } from '../DateSeparator'; +import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; export const ThreadHead = (props: MessageProps) => { - const { ThreadStart = DefaultThreadStart } = useComponentContext(); + const { DateSeparator = DefaultDateSeparator, ThreadStart = DefaultThreadStart } = + useComponentContext(); const parentCreatedAt = convertTimestampToDate(props.message.created_at); return (
- {parentCreatedAt ? : null} + {parentCreatedAt ? ( + + ) : null}
diff --git a/src/components/Thread/__tests__/ThreadHead.test.tsx b/src/components/Thread/__tests__/ThreadHead.test.tsx new file mode 100644 index 0000000000..ac0c82b693 --- /dev/null +++ b/src/components/Thread/__tests__/ThreadHead.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { WithComponents } from '../../../context'; +import { generateMessage } from '../../../mock-builders'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; +import { ThreadHead } from '../ThreadHead'; + +// The parent message itself is out of scope here; only the separator above it is checked. +vi.mock('../../Message/Message', async (importOriginal) => ({ + ...(await importOriginal()), + Message: () =>
, +})); + +describe('ThreadHead', () => { + it('renders the DateSeparator from ComponentContext with the parent message date', () => { + const createdAt = new Date('2026-01-02T03:04:05.000Z'); + const message = generateMessage({ created_at: convertDateToTimestamp(createdAt) }); + const CustomDateSeparator = vi.fn(() =>
); + const ThreadStart = () => null; + + render( + + + , + ); + + expect(screen.getByTestId('custom-date-separator')).toBeInTheDocument(); + expect(screen.getByTestId('parent-message')).toBeInTheDocument(); + expect(CustomDateSeparator).toHaveBeenCalledWith( + expect.objectContaining({ date: createdAt }), + undefined, + ); + }); + + it("passes the list's formatDate to the DateSeparator", () => { + const message = generateMessage({ + created_at: convertDateToTimestamp(new Date('2026-01-02T03:04:05.000Z')), + }); + const formatDate = vi.fn(() => 'formatted'); + const CustomDateSeparator = vi.fn(() => null); + + render( + null }} + > + + , + ); + + expect(CustomDateSeparator).toHaveBeenCalledWith( + expect.objectContaining({ formatDate }), + undefined, + ); + }); +}); diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index c4c8b8c6b7..3a0d8670c6 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -5,10 +5,10 @@ import clsx from 'clsx'; import type { MessagePaginatorAggregateState, ThreadState } from 'stream-chat'; -import { Timestamp } from '../../Message/Timestamp'; +import { Timestamp as DefaultTimestamp } from '../../Message/Timestamp'; import { - Avatar, type AvatarProps, + Avatar as DefaultAvatar, AvatarStack as DefaultAvatarStack, } from '../../Avatar'; import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../Avatar/utils'; @@ -24,7 +24,7 @@ import { useThreadListItemContext } from './ThreadListItem'; import { useStateStore } from '../../../store'; import { Badge } from '../../Badge'; import { - SummarizedMessagePreview, + SummarizedMessagePreview as DefaultSummarizedMessagePreview, useLatestMessagePreview, } from '../../SummarizedMessagePreview'; import { @@ -90,8 +90,11 @@ export const ThreadListItemUI = ({ const { announceInteraction } = useInteractionAnnouncements(); const { isThreadActive, openThread } = useWorkspaceNavigation(); const { + Avatar = DefaultAvatar, AvatarStack = DefaultAvatarStack, extractDisplayInfo = defaultExtractDisplayInfo, + SummarizedMessagePreview = DefaultSummarizedMessagePreview, + Timestamp = DefaultTimestamp, } = useComponentContext(); const isSelected = isThreadActive(thread.id); diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx index 11609ff10e..cbca5e37dd 100644 --- a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx +++ b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx @@ -5,11 +5,12 @@ import type { ThreadManagerState } from 'stream-chat'; import { useChatContext, + useComponentContext, useComponentContextIcons, useTranslationContext, } from '../../../context'; import { useStateStore } from '../../../store'; -import { LoadingIndicator } from '../../Loading'; +import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading'; const selector = (nextValue: ThreadManagerState) => ({ unseenThreadIds: nextValue.unseenThreadIds, @@ -17,6 +18,7 @@ const selector = (nextValue: ThreadManagerState) => ({ export const ThreadListUnseenThreadsBanner = () => { const { IconRefresh } = useComponentContextIcons(); + const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); const { client } = useChatContext(); const { t } = useTranslationContext(); const { unseenThreadIds } = useStateStore(client.threads.state, selector); diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx index 4db09c9f55..2489129da3 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx @@ -4,6 +4,7 @@ import { axe } from '../../../../../axe-helper'; import { ThreadListItemUI } from '../ThreadListItemUI'; import { mockT } from '../../../../mock-builders/translator'; +import { convertDateToTimestamp } from '../../../../mock-builders/generator/time'; const { announceInteraction } = vi.hoisted(() => ({ announceInteraction: vi.fn() })); @@ -21,11 +22,12 @@ const mockOpenThread = vi.fn(); const mockIsThreadActive = vi.fn(); const mockUseThreadListItemContext = vi.fn(); const mockUseChannelPreviewInfo = vi.fn(); +const mockUseComponentContext = vi.fn(); // `isThreadActive(id)` drives the selected state; a click opens the thread via `openThread`. vi.mock('../../../../context', () => ({ useChatContext: () => mockUseChatContext(), - useComponentContext: () => ({}), + useComponentContext: () => mockUseComponentContext(), useTranslationContext: () => mockUseTranslationContext(), useWorkspaceNavigation: () => ({ isThreadActive: mockIsThreadActive, @@ -83,6 +85,7 @@ describe('ThreadListItemUI', () => { }); mockUseThreadListItemContext.mockReturnValue(thread); mockUseChannelPreviewInfo.mockReturnValue({ displayTitle: 'General' }); + mockUseComponentContext.mockReturnValue({}); mockUseStateStore.mockReturnValue({ channel: {}, deletedAt: undefined, @@ -174,6 +177,58 @@ describe('ThreadListItemUI', () => { expect(announceInteraction).not.toHaveBeenCalled(); }); + it('renders the Avatar, SummarizedMessagePreview and Timestamp from ComponentContext', () => { + const parentMessage = { text: 'hello world' }; + const replyDate = new Date('2026-01-02T03:04:05.000Z'); + const latestReply = { + created_at: convertDateToTimestamp(replyDate), + user: { id: 'bob', image: 'bob.png', name: 'Bob' }, + }; + mockUseStateStore.mockReturnValue({ + channel: {}, + deletedAt: undefined, + latestReply, + ownUnreadMessageCount: 0, + parentMessage, + participants: [{ id: 'bob' }, { id: 'carol' }], + replyCount: 1, + }); + const CustomAvatar = vi.fn(() => ); + const CustomSummarizedMessagePreview = vi.fn(() => ( + + )); + const CustomTimestamp = vi.fn(() => ); + mockUseComponentContext.mockReturnValue({ + Avatar: CustomAvatar, + SummarizedMessagePreview: CustomSummarizedMessagePreview, + Timestamp: CustomTimestamp, + }); + + render(); + + expect(screen.getByTestId('custom-avatar')).toBeInTheDocument(); + expect(screen.getByTestId('custom-summary')).toBeInTheDocument(); + expect(screen.getByTestId('custom-timestamp')).toBeInTheDocument(); + expect(screen.queryByTestId('avatar')).not.toBeInTheDocument(); + expect(screen.queryByTestId('summary')).not.toBeInTheDocument(); + expect(screen.queryByTestId('timestamp')).not.toBeInTheDocument(); + expect(CustomAvatar).toHaveBeenCalledWith( + expect.objectContaining({ imageUrl: 'bob.png', size: 'xl', userName: 'Bob' }), + undefined, + ); + expect(CustomSummarizedMessagePreview).toHaveBeenCalledWith( + expect.objectContaining({ latestMessage: parentMessage, participantCount: 2 }), + undefined, + ); + expect(CustomTimestamp).toHaveBeenCalledWith( + expect.objectContaining({ + customClass: 'str-chat__thread-list-item__timestamp', + timestamp: replyDate, + }), + undefined, + ); + }); + it('passes axe checks in listbox context', async () => { mockIsThreadActive.mockReturnValue(true); diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx index 470bc416fc..6a609b875f 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx @@ -89,6 +89,18 @@ describe('ThreadListUnseenThreadsBanner', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + it('renders the LoadingIndicator from ComponentContext while its reload is pending', () => { + vi.spyOn(client.threads, 'reload').mockReturnValue(deferred().promise); + client.threads.state.partialNext({ unseenThreadIds: ['unseen-thread'] }); + renderWithClient(); + + expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button')); + + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); + }); + it('keeps the banner after a failed reload', async () => { // `reload()` logs a failure and leaves `unseenThreadIds` in place. const pending = deferred(); diff --git a/src/components/VideoPlayer/VideoThumbnail.tsx b/src/components/VideoPlayer/VideoThumbnail.tsx index 2674ee3826..c568f389dc 100644 --- a/src/components/VideoPlayer/VideoThumbnail.tsx +++ b/src/components/VideoPlayer/VideoThumbnail.tsx @@ -1,8 +1,12 @@ -import { BaseImage, type BaseImageProps } from '../BaseImage'; +import { type BaseImageProps, BaseImage as DefaultBaseImage } from '../BaseImage'; import { Button } from '../Button'; import clsx from 'clsx'; import React from 'react'; -import { useComponentContextIcons, useTranslationContext } from '../../context'; +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; export type VideoThumbnailProps = BaseImageProps & { onPlay?: () => void; @@ -14,6 +18,7 @@ export const VideoThumbnail = ({ ...imageProps }: VideoThumbnailProps) => { const { IconPlayFill } = useComponentContextIcons(); + const { BaseImage = DefaultBaseImage } = useComponentContext(); const { t } = useTranslationContext(); return ( diff --git a/src/components/VideoPlayer/__tests__/VideoThumbnail.test.tsx b/src/components/VideoPlayer/__tests__/VideoThumbnail.test.tsx new file mode 100644 index 0000000000..9800894fa0 --- /dev/null +++ b/src/components/VideoPlayer/__tests__/VideoThumbnail.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { WithComponents } from '../../../context'; +import { VideoThumbnail } from '../VideoThumbnail'; + +describe('VideoThumbnail', () => { + it('renders the BaseImage from ComponentContext with the image props', () => { + const CustomBaseImage = ({ + className, + src, + }: { + className?: string; + src?: string; + }) =>
; + + render( + + + , + ); + + const image = screen.getByTestId('custom-base-image'); + expect(image).toHaveAttribute('data-src', 'https://example.com/thumb.png'); + expect(image).toHaveClass( + 'str-chat__message-attachment__video-thumbnail-image', + 'custom-class', + ); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); +}); diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index bcfff8b63f..333520c025 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -3,7 +3,6 @@ import React, { useContext } from 'react'; import type { UserResponse } from 'stream-chat'; import { - type AttachmentPreviewListProps, type AttachmentProps, type AvatarStackProps, type BaseImageProps, @@ -22,14 +21,14 @@ import { type ListLoadErrorIndicatorProps, type LoadingIndicatorProps, type MessageBouncePromptProps, - type MessageComposerProps, type MessageDeletedProps, - type MessageEditedIndicatorProps, + type MessageListItemProps, + type MessageListWrapperProps, type MessageProps, type MessageReactionsDetailProps, type MessageReactionsProps, type MessageRepliesCountButtonProps, - type MessageStatusProps, + type MessageTextProps, type MessageTimestampProps, type MessageUIComponentProps, type ModalGalleryProps, @@ -41,7 +40,6 @@ import { type PollCreationDialogControlsProps, type PollCreationDialogProps, type PollOptionSelectorProps, - type QuotedMessagePreviewProps, type ReactionOptions, type ReactionSelector, type ReactionSelectorProps, @@ -49,19 +47,17 @@ import { type ReminderNotificationProps, type ScrollToLatestMessageButtonProps, type SearchResultsPresearchProps, - type SearchSourceResultListProps, + type SearchSourceResultsProps, type SendButtonProps, type ShareLocationDialogProps, type StartRecordingAudioButtonProps, type StreamedMessageTextProps, - type TextareaComposerProps, type ThreadListItemProps, type ThreadListItemUIProps, type TimestampProps, type TranslationIndicatorProps, type UnreadMessagesNotificationProps, type UnreadMessagesSeparatorProps, - type VoiceRecordingPreviewSlotProps, } from '../components'; import type { @@ -78,7 +74,6 @@ import type { FileSizeIndicatorProps } from '../components/Attachment/components import type { CommandChipProps } from '../components/MessageComposer/CommandChip'; import type { ProgressIndicatorProps } from '../components/Loading/progress-indicators'; import type { UploadedSizeIndicatorProps } from '../components/Loading/UploadedSizeIndicator'; -import type { NotificationAnnouncerProps } from '../components/Accessibility'; import type { SummarizedMessagePreviewProps } from '../components/SummarizedMessagePreview'; import type { TypingIndicatorProps } from '../components/TypingIndicator'; import type { AttachmentUploadProgressIndicatorProps } from '../components/Attachment/components/AttachmentUploadProgressIndicator'; @@ -111,12 +106,18 @@ export type ComponentContextValue = { * the icon. */ AttachmentFileIcon?: React.ComponentType; - /** Attachment previews in the `MessageComposer`. */ - AttachmentPreviewList?: React.ComponentType; + /** + * Attachment previews in the `MessageComposer`, rendered without props. To change its parts, + * render `AttachmentPreviewList` with your own `AttachmentPreviewListProps`. + */ + AttachmentPreviewList?: React.ComponentType; /** Control for adding attachments to the `MessageComposer`. */ AttachmentSelector?: React.ComponentType; - /** Slot for the voice recording preview above the composer attachments. */ - VoiceRecordingPreviewSlot?: React.ComponentType; + /** + * Voice recording preview above the composer attachments, rendered without props. To change the + * preview, render `VoiceRecordingPreviewSlot` with your own `VoiceRecordingPreview`. + */ + VoiceRecordingPreviewSlot?: React.ComponentType; /** Contents of the attachment selector's initiation button. */ AttachmentSelectorInitiationButtonContents?: React.ComponentType; /** Audio recorder in the `MessageComposer`. */ @@ -193,8 +194,11 @@ export type ComponentContextValue = { * render `MessageComposerPreviews` with your own `messageComposerPreviewSet` to change it. */ MessageComposerPreviews?: React.ComponentType; - /** How the message composer renders. */ - MessageComposerUI?: React.ComponentType; + /** + * How the message composer renders, without props. `MessageComposer` passes its own props + * through `MessageComposerContext`. + */ + MessageComposerUI?: React.ComponentType; /** Link previews in the message composer. */ LinkPreviewList?: React.ComponentType; /** Loading indicator, e.g. while a message list loads older messages. */ @@ -226,8 +230,11 @@ export type ComponentContextValue = { Notification?: React.ForwardRefExoticComponent< NotificationProps & React.RefAttributes >; - /** Screen-reader announcements of notifications. */ - NotificationAnnouncer?: React.ComponentType; + /** + * Screen-reader announcements of notifications, rendered without props. To filter or reword the + * announcements, render `NotificationAnnouncer` with your own `NotificationAnnouncerProps`. + */ + NotificationAnnouncer?: React.ComponentType; /** Displays notifications. */ NotificationList?: React.ComponentType; /** Shown when new messages arrive while the list is scrolled up. */ @@ -236,10 +243,15 @@ export type ComponentContextValue = { ScrollToLatestMessageButton?: React.ComponentType; /** Button that opens a message's thread, showing its reply count and participants. */ MessageRepliesCountButton?: React.ComponentType; - /** Message delivery status. */ - MessageStatus?: React.ComponentType; + /** + * Message delivery status, rendered without props. To change its parts, render `MessageStatus` + * with your own `MessageStatusProps`. + */ + MessageStatus?: React.ComponentType; /** System messages. */ MessageSystem?: React.ComponentType; + /** Text of a message, also of an AI-generated one while `StreamedMessageText` streams it. */ + MessageText?: React.ComponentType; /** Timestamp on a message. */ MessageTimestamp?: React.ComponentType; /** Modal for viewing content. */ @@ -270,14 +282,18 @@ export type ComponentContextValue = { PollOptionSelector?: React.ComponentType; /** Quoted message UI on a sent message. */ QuotedMessage?: React.ComponentType; - /** Quoted message preview in the message composer. */ - QuotedMessagePreview?: React.ComponentType; + /** + * Quoted message preview in the message composer, rendered without props. To change it, render + * `QuotedMessagePreview` with your own `QuotedMessagePreviewProps`. + */ + QuotedMessagePreview?: React.ComponentType; /** * Reaction options read by `ReactionSelector`, `MessageReactions`, `MessageReactionsDetail` and * the reaction handler. Defaults to `defaultReactionOptions`. */ reactionOptions?: ReactionOptions; - ReactionSelector?: React.ForwardRefExoticComponent; + /** Quick reaction picker of a message. */ + ReactionSelector?: React.ComponentType; ReactionSelectorExtendedList?: React.ComponentType< ComponentProps<(typeof ReactionSelector)['ExtendedList']> >; @@ -290,8 +306,11 @@ export type ComponentContextValue = { ReminderNotification?: React.ComponentType; /** Translation indicator, shown when the message has `i18n`. */ MessageTranslationIndicator?: React.ComponentType; - /** Edited indicator and tooltip for an edited message. */ - MessageEditedIndicator?: React.ComponentType; + /** + * Edited indicator and tooltip for an edited message, rendered without props. To change it, + * render `MessageEditedIndicator` with your own `MessageEditedIndicatorProps`. + */ + MessageEditedIndicator?: React.ComponentType; Search?: React.ComponentType; /** The UI where the search string is entered. */ SearchBar?: React.ComponentType; @@ -301,12 +320,15 @@ export type ComponentContextValue = { SearchResultsHeader?: React.ComponentType; /** Search results pane shown before the first search query for a source. */ SearchResultsPresearch?: React.ComponentType; - /** The items found for a search source. */ - SearchSourceResultList?: React.ComponentType; + /** + * The items found for a search source, rendered without props. To change its paging or items, + * render `SearchSourceResultList` with your own `SearchSourceResultListProps`. + */ + SearchSourceResultList?: React.ComponentType; /** Marks the end of the last page of results for a searched source. */ SearchSourceResultListFooter?: React.ComponentType; /** Result items of a search source pane. */ - SearchSourceResults?: React.ComponentType; + SearchSourceResults?: React.ComponentType; /** Results UI of a search source with 0 items found. */ SearchSourceResultsEmpty?: React.ComponentType; /** Header content of a search source's results. The default renders nothing. */ @@ -322,8 +344,11 @@ export type ComponentContextValue = { StartRecordingAudioButton?: React.ComponentType; StopAIGenerationButton?: React.ComponentType | null; StreamedMessageText?: React.ComponentType; - /** Message text input. */ - TextareaComposer?: React.ComponentType; + /** + * Message text input, rendered without props. `MessageComposer` passes its textarea props + * through `MessageComposerContext`. + */ + TextareaComposer?: React.ComponentType; /** The thread's parent message, rendered above the replies in a thread's message list. */ ThreadHead?: React.ComponentType; ThreadListEmptyPlaceholder?: React.ComponentType; @@ -358,9 +383,9 @@ export type ComponentContextValue = { /** Replaces `MessageUI` inside the `VirtualizedMessageList`. No default. */ VirtualMessage?: React.ComponentType; /** Wraps the `MessageList` children. Defaults to `ul`. */ - MessageListWrapper?: React.ComponentType; + MessageListWrapper?: React.ComponentType; /** Wraps each element of the `MessageList`. Defaults to `li`. */ - MessageListItem?: React.ComponentType; + MessageListItem?: React.ComponentType; }; export const ComponentContext = React.createContext({}); diff --git a/src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx b/src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx index 86293a1351..495dce3ba4 100644 --- a/src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx +++ b/src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx @@ -1,6 +1,7 @@ import type { SearchSource, SearchSourceState } from 'stream-chat'; import { useStateStore } from '../../store'; -import { LoadingIndicator } from '../../components/Loading'; +import { LoadingIndicator as DefaultLoadingIndicator } from '../../components/Loading'; +import { useComponentContext } from '../../context'; const searchSourceFooterStateSelector = (state: SearchSourceState) => ({ hasNextPage: state.hasNext, @@ -14,6 +15,7 @@ export type ChannelMembersViewListFooterProps = { export const ChannelDetailListLoadingIndicator = ({ searchSource, }: ChannelMembersViewListFooterProps) => { + const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); const { hasNextPage, isLoading } = useStateStore( searchSource.state, searchSourceFooterStateSelector, diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx index f1e0e4405b..ada50d3500 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx @@ -3,9 +3,9 @@ import type { ComponentProps } from 'react'; import React, { forwardRef, useCallback, useMemo } from 'react'; import { GroupedVirtuoso } from 'react-virtuoso'; -import { useTranslationContext } from '../../../../context'; +import { useComponentContext, useTranslationContext } from '../../../../context'; import { getDateString } from '../../../../i18n/utils'; -import { FileSizeIndicator } from '../../../../components/Attachment/components/FileSizeIndicator'; +import { FileSizeIndicator as DefaultFileSizeIndicator } from '../../../../components/Attachment/components/FileSizeIndicator'; import { Prompt } from '../../../../components/Dialog'; import { FileIcon } from '../../../../components/FileIcon'; import { ListItemLayout } from '../../../../components/ListItemLayout'; @@ -67,6 +67,7 @@ const getAttachmentFileName = (attachment: ChannelFileItem['attachment']) => attachment.title || attachment.fallback || ''; const ChannelFileListItem = ({ item }: { item: ChannelFileItem }) => { + const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext(); const { attachment } = item; const fileName = getAttachmentFileName(attachment); const assetUrl = attachment.asset_url; @@ -95,7 +96,7 @@ const ChannelFileListItem = ({ item }: { item: ChannelFileItem }) => { title: fileName, titleClassName: 'str-chat__channel-detail__files-view__list-item__name', }), - [fileSize, fileName, LeadingSlot], + [FileSizeIndicator, fileSize, fileName, LeadingSlot], ); const linkRootProps = useMemo( diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx index 1360bbaf1d..5eba98414a 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx @@ -6,6 +6,7 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { useChatContext, + useComponentContext, useComponentContextIcons, useTranslationContext, } from '../../../../../context'; @@ -211,6 +212,9 @@ describe('ChannelFilesView', () => { client: { userID: 'user-1' }, } as ReturnType); + // Auto-mocked module: no component overrides unless a test sets them. + vi.mocked(useComponentContext).mockReturnValue({}); + vi.mocked(useStateStore).mockReturnValue({ isLoading: false, messages, @@ -251,6 +255,26 @@ describe('ChannelFilesView', () => { expect(screen.queryByText('scraped-link-preview')).not.toBeInTheDocument(); }); + it('renders the FileSizeIndicator from ComponentContext with the attachment file size', () => { + const CustomFileSizeIndicator = ({ fileSize }: { fileSize?: number | string }) => ( + {fileSize} + ); + vi.mocked(useComponentContext).mockReturnValue({ + FileSizeIndicator: CustomFileSizeIndicator, + }); + + renderView(); + + expect( + screen.getAllByTestId('custom-file-size-indicator').map((node) => node.textContent), + ).toEqual([ + String(4 * 1024 * 1024), + String(7 * 1024 * 1024), + String(6 * 1024 * 1024), + ]); + expect(screen.queryByTestId('file-size-indicator')).not.toBeInTheDocument(); + }); + it('groups attachments into descending month sections', () => { renderView(); diff --git a/src/plugins/ChannelDetail/__tests__/ChannelDetailListLoadingIndicator.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelDetailListLoadingIndicator.test.tsx new file mode 100644 index 0000000000..ae07f8fdc6 --- /dev/null +++ b/src/plugins/ChannelDetail/__tests__/ChannelDetailListLoadingIndicator.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { StateStore } from '@stream-io/state-store'; +import type { SearchSource } from 'stream-chat'; +import { fromPartial } from '@total-typescript/shoehorn'; + +import { WithComponents } from '../../../context'; +import { ChannelDetailListLoadingIndicator } from '../ChannelDetailListLoadingIndicator'; + +describe('ChannelDetailListLoadingIndicator', () => { + it('renders the LoadingIndicator from ComponentContext while the next page loads', () => { + const searchSource = fromPartial({ + state: new StateStore({ hasNext: true, isLoading: true }), + }); + const CustomLoadingIndicator = () =>
; + + const { container } = render( + + + , + ); + + expect(screen.getByTestId('custom-loading-indicator')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__loading-indicator'), + ).not.toBeInTheDocument(); + }); +}); From 26d6c1abfcc5ef4bc7fed3d2d5930f0ed2269d54 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Nov 2026 01:00:21 +0100 Subject: [PATCH 3/5] fix: end useSameValuesReference comparison on cyclic values, correct REACT-1277 migration docs useSameValuesReference treats a pair of objects already being compared as equal, so cyclic values no longer overflow the stack. Migration docs: emoji autocomplete is opt-in and needs emoji-mart initialized, and setSetupFunction replaces an existing composer setup function; custom Virtuoso components lose additionalMessageComposerProps from VirtuosoContext; a custom ChannelListItemUI that used activeChannel reads active or useWorkspaceNavigation().openChannels; section 17 no longer lists MessageReactionsDetail.sort. Ticket: REACT-1277 --- ai-docs/ai-migration-v14-v15.md | 14 +++++--- ai-docs/v15-breaking-changes.md | 13 +++++--- .../__tests__/useSameValuesReference.test.ts | 18 +++++++++++ src/utils/useSameValuesReference.ts | 32 ++++++++++++------- 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 2911973541..b54e92cdba 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1421,7 +1421,7 @@ export const ChannelCount = () => { | `setActiveChannel`, `watchers` | `useWorkspaceNavigation().openChannel(channel, { event })`, or the `onSelect` prop ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | | `channelUpdateCount`, `key` | None; the item subscribes to the channel's stores | | `active` | Unchanged prop; it now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. `ChannelListItem` passes the resolved value to `ChannelListItemUI` as `active`. The SDK's CSS highlights `.str-chat__channel-list-item[aria-selected=true]` only (v14 also matched `[aria-pressed=true]`), so a custom row that keeps the SDK class sets `role="option"` and `aria-selected={active}` | -| `activeChannel` | `active`. v14 ignored the prop too: the item compared `useChatContext().channel`. A custom `ChannelListItemUI` that read `props.activeChannel` reads `active` | +| `activeChannel` | `active`. v14 forwarded `activeChannel` to `ChannelListItemUI` but computed the default `active` from `useChatContext().channel`. A custom row that compared `props.activeChannel` with its `channel` reads `active`; one that needs the open channel itself reads `useWorkspaceNavigation().openChannels` | The `getLatestMessagePreview` and `getLatestMessagePreviewText` utilities are still exported; only their `t` / `userLanguage` parameter types changed. Prefer `WithComponents` for the slot override: it merges over the parent context, while `ComponentProvider` replaces it. Neither `Chat` nor `Channel` takes component props. @@ -2472,7 +2472,7 @@ export const List = () => ( ### `additionalMessageComposerProps` is removed from messages and message lists -`Message`, `MessageList` and `VirtualizedMessageList` no longer take `additionalMessageComposerProps`. Passing it is a compile error; in untyped code it is ignored. Nothing changes on screen: no message component read it in v14 either, so delete it. If the props were meant for the composer, pass them to the `MessageComposer` you render; that turns them on, because v14 never applied them. A message is edited in that composer. +`Message`, `MessageList` and `VirtualizedMessageList` no longer take `additionalMessageComposerProps`, and `VirtuosoContext` (the context of `additionalVirtuosoProps.components`) no longer carries it. Passing it is a compile error; in untyped code it is ignored. A custom Virtuoso component that read it from that context gets `undefined`: give it the value through your own context instead. Nothing changes on screen: no message component read it in v14 either, so delete it. If the props were meant for the composer, pass them to the `MessageComposer` you render; that turns them on, because v14 never applied them. A message is edited in that composer. ```tsx // v14 @@ -2879,7 +2879,7 @@ Only the four built-in action types (`uploadFile`, `createPoll`, `addLocation`, ### `emojiSearchIndex` and `parent` are removed from `MessageComposer` -`MessageComposer` no longer takes `emojiSearchIndex` or `parent`, the `emojiSearchIndex` override key is removed, and `useMessageComposerContext()` no longer returns `emojiSearchIndex`, `parent` or `audioRecordingConfig`. In v14 they only reached the composer context, and nothing read them there, so emoji autocomplete already needed the middleware. Register it on every composer, and drop the props: +`MessageComposer` no longer takes `emojiSearchIndex` or `parent`, the `emojiSearchIndex` override key is removed, and `useMessageComposerContext()` no longer returns `emojiSearchIndex`, `parent` or `audioRecordingConfig`. In v14 they only reached the composer context, and nothing read them there, so they turned on nothing: drop them. Emoji autocomplete comes from a text composer middleware. Register it only if the app wants `:shortcode` suggestions, which v14 did not show from these props. `client.config.setSetupFunction()` replaces the previous setup function for the key, so if the app already registers one for `messageComposer`, add the `insert` call to it and keep its teardown: ```tsx // v14 @@ -2891,7 +2891,11 @@ Only the four built-in action types (`uploadFile`, `createPoll`, `addLocation`, ```tsx import type { TextComposerMiddleware } from 'stream-chat'; import { createTextComposerEmojiMiddleware } from 'stream-chat-react/emojis'; -import { SearchIndex } from 'emoji-mart'; +import data from '@emoji-mart/data'; +import { init, SearchIndex } from 'emoji-mart'; + +// `SearchIndex.search()` returns nothing until emoji-mart has its data. +init({ data }); // Before the composers are created, e.g. right after creating the client. client.config.setSetupFunction('messageComposer', ({ composer }) => { @@ -3332,7 +3336,7 @@ React props typed with `stream-chat` types change with them. The React APIs are **Messages:** -- **Reaction sort:** `reactionDetailsSort` (on `Message`, `MessageList`, `VirtualizedMessageList`, `MessageContext`, `MessageReactions`, `MessageReactionsDetail`, `VirtuosoContext`), `MessageReactionsDetail.sort`, and the `sort` argument of `handleFetchReactions` / `useReactionsFetcher`: `ReactionSort` → `SortParamRequest[]` (`[{ field: 'created_at', direction: -1 }]`). +- **Reaction sort:** `reactionDetailsSort` (on `Message`, `MessageList`, `VirtualizedMessageList`, `MessageContext`, `MessageReactions`, `MessageReactionsDetail`, `VirtuosoContext`) and the `sort` argument of `handleFetchReactions` / `useReactionsFetcher`: `ReactionSort` → `SortParamRequest[]`. `MessageReactionsDetail.sort` is removed; use `reactionDetailsSort` ([section 12](#messagereactionsreverse-and-messagereactionsdetailsort--dialogid-are-removed)) (`[{ field: 'created_at', direction: -1 }]`). - **Delete options:** `handleDelete` / `useDeleteHandler` take `{ delete_for_me?, hard? }` (was `{ deleteForMe?, hardDelete? }`). - **Mutes:** `isUserMuted(message, mutes)` takes `UserMuteResponse[]` (`target` is optional). - **Moderation:** `isMessageBounced` / `isMessageBlocked` read only `message.moderation`; `moderation_details` is gone from `LocalMessage`. diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index f61f482c94..6051bb097e 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -1792,7 +1792,7 @@ const { paginator } = useChannelListContext(); - `setActiveChannel` / `watchers` -> `useWorkspaceNavigation().openChannel(channel, { event })` or the `onSelect` prop (BC-013). - `channelUpdateCount` / `key`: none needed; the item subscribes to the channel's stores. - `active` now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. - - `activeChannel`: v14 ignored the prop too. The item compared `useChatContext().channel` (BC-013), and `ChannelList` passed `activeChannel` only for `ChannelListItemUI` to read. A custom `ChannelListItemUI` reads `active` instead. + - `activeChannel`: v14 ignored the prop too. The item compared `useChatContext().channel` (BC-013), and `ChannelList` passed `activeChannel` only for `ChannelListItemUI` to read. A custom `ChannelListItemUI` that compared it with its `channel` reads `active` instead; one that needs the open channel itself reads `useWorkspaceNavigation().openChannels`. - Evidence: - `release-v14:src/components/ChannelListItem/ChannelListItem.tsx:37,39,56,58,62,68,72,74`, `:92` (`channel: activeChannel` from `useChatContext`); `release-v14:src/components/ChannelListItem/ChannelListItemTimestamp.tsx:9,12` - `master:src/components/ChannelListItem/ChannelListItem.tsx:22-65,88-109`; `master:src/components/ChannelListItem/ChannelListItemTimestamp.tsx:10,14`; `master:src/components/ChannelListItem/ChannelListItemUI.tsx:95-102,152,160-161`; `master:src/components/ChannelListItem/utils.tsx:260,324`; `master:src/context/ComponentContext.tsx:143` @@ -3159,7 +3159,7 @@ export const List = () => ( - User impact: compile error on passing `additionalMessageComposerProps` to `Message`, `MessageList` or `VirtualizedMessageList`, and on reading it in a custom `ThreadHead`. In untyped code it is ignored. Nothing changes on screen: no message component read it in v14 either. The lists passed it to every `Message`, which passed it on to the memoized message, and nothing rendered a composer with it. - Old API: - `MessageProps.additionalMessageComposerProps?: MessageComposerProps`, picked into `MessageListProps` and `VirtualizedMessageListProps` -- Replacement: delete it. If the props were meant for the composer, pass them to the `MessageComposer` you render (a thread's composer is a `MessageComposer` child of `Thread`); that turns them on, because v14 never applied them. Edits happen in that composer, so there is no separate edit composer to configure. +- Replacement: delete it. A custom component in `additionalVirtuosoProps.components` that read it from `VirtuosoContext` gets `undefined`; give it the value through your own context. If the props were meant for the composer, pass them to the `MessageComposer` you render (a thread's composer is a `MessageComposer` child of `Thread`); that turns them on, because v14 never applied them. Edits happen in that composer, so there is no separate edit composer to configure. - Also removed from `VirtuosoContext` (the context of `additionalVirtuosoProps.components`). - Evidence: - `release-v14:src/components/Message/types.ts:19`; `release-v14:src/components/MessageList/MessageList.tsx:229`, `:495`; `release-v14:src/components/MessageList/VirtualizedMessageList.tsx:85`, `:202`, `:599` @@ -3532,7 +3532,8 @@ import { - `ComponentContextValue.emojiSearchIndex` - `MessageComposerContextValue.emojiSearchIndex`, `.parent`, `.audioRecordingConfig` (the last was typed but never set) - Replacement: - - emoji autocomplete: register `createTextComposerEmojiMiddleware(SearchIndex)` from `stream-chat-react/emojis` on every composer with `client.config.setSetupFunction('messageComposer', ...)`. The `EmojiSearchIndex` type is still exported. + - drop the props: they turned on nothing in v14. + - emoji autocomplete, only if the app wants `:shortcode` suggestions (v14 showed none from these props): initialize emoji-mart (`init({ data })`), then register `createTextComposerEmojiMiddleware(SearchIndex)` from `stream-chat-react/emojis` on every composer with `client.config.setSetupFunction('messageComposer', ...)`. That call replaces a previously registered setup function for the key, so an app that already has one adds the `insert` call to it. The `EmojiSearchIndex` type is still exported. - `parent`: the composer belongs to the thread already. Where you need the parent message, subscribe with `useStateStore(useThreadContext()?.state, selector)` and read `parentMessage`. - `audioRecordingConfig` stays a `MessageComposer` prop; the recorder reads it, the context does not carry it. - Evidence: @@ -3554,7 +3555,11 @@ After (v15): ```tsx import type { TextComposerMiddleware } from 'stream-chat'; import { createTextComposerEmojiMiddleware } from 'stream-chat-react/emojis'; -import { SearchIndex } from 'emoji-mart'; +import data from '@emoji-mart/data'; +import { init, SearchIndex } from 'emoji-mart'; + +// `SearchIndex.search()` returns nothing until emoji-mart has its data. +init({ data }); client.config.setSetupFunction('messageComposer', ({ composer }) => { composer.textComposer.middlewareExecutor.insert({ diff --git a/src/utils/__tests__/useSameValuesReference.test.ts b/src/utils/__tests__/useSameValuesReference.test.ts index db8531768b..87affc076b 100644 --- a/src/utils/__tests__/useSameValuesReference.test.ts +++ b/src/utils/__tests__/useSameValuesReference.test.ts @@ -85,4 +85,22 @@ describe('useSameValuesReference', () => { expect(result.current).toBe('one'); }); + + it('ends the comparison for cyclic values', () => { + type Node = { name: string; self?: Node }; + const cyclic = (name: string) => { + const node: Node = { name }; + node.self = node; + return node; + }; + const { rerender, result } = renderValue(cyclic('a')); + const first = result.current; + + rerender({ value: cyclic('a') }); + expect(result.current).toBe(first); + + const other = cyclic('b'); + rerender({ value: other }); + expect(result.current).toBe(other); + }); }); diff --git a/src/utils/useSameValuesReference.ts b/src/utils/useSameValuesReference.ts index 05bebf9369..e86eba9821 100644 --- a/src/utils/useSameValuesReference.ts +++ b/src/utils/useSameValuesReference.ts @@ -8,22 +8,32 @@ const isPlainObject = (value: unknown): value is Record => { /** * Whether two values hold the same data: plain objects and arrays are compared key by key, at - * every depth, and anything else (functions, class instances, dates) by identity. + * every depth, and anything else (functions, class instances, dates) by identity. A pair already + * being compared further up counts as equal, so cyclic values end the comparison. */ -const hasSameValues = (a: unknown, b: unknown): boolean => { +const hasSameValues = ( + a: unknown, + b: unknown, + pairsInProgress: WeakMap> = new WeakMap(), +): boolean => { if (Object.is(a, b)) return true; - if (Array.isArray(a) && Array.isArray(b)) { - return ( - a.length === b.length && a.every((item, index) => hasSameValues(item, b[index])) - ); - } - if (!isPlainObject(a) || !isPlainObject(b)) return false; - const keys = Object.keys(a); + const bothArrays = Array.isArray(a) && Array.isArray(b); + if (!bothArrays && (!isPlainObject(a) || !isPlainObject(b))) return false; + + const left = a as Record; + const right = b as Record; + if (pairsInProgress.get(left)?.has(right)) return true; + const comparedWithLeft = pairsInProgress.get(left) ?? new WeakSet(); + comparedWithLeft.add(right); + pairsInProgress.set(left, comparedWithLeft); + + const keys = Object.keys(left); return ( - keys.length === Object.keys(b).length && + keys.length === Object.keys(right).length && keys.every( (key) => - Object.prototype.hasOwnProperty.call(b, key) && hasSameValues(a[key], b[key]), + Object.prototype.hasOwnProperty.call(right, key) && + hasSameValues(left[key], right[key], pairsInProgress), ) ); }; From b481f432579ff911648a54280ad6e1f45b23b983 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Nov 2026 01:04:34 +0100 Subject: [PATCH 4/5] fix: keep array length check in useSameValuesReference, clarify activeChannel migration The cycle-safe comparison dropped the array length check, so an empty and a sparse array compared equal. The check is back, with a regression test. The activeChannel migration step now says v14 only forwarded the prop to ChannelListItemUI, that active defaults to the workspace selection, and how an app with its own selection keeps the comparison. Ticket: REACT-1277 --- ai-docs/ai-migration-v14-v15.md | 16 ++++++++-------- ai-docs/v15-breaking-changes.md | 2 +- .../__tests__/useSameValuesReference.test.ts | 9 +++++++++ src/utils/useSameValuesReference.ts | 1 + 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index b54e92cdba..69bcf016c9 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1414,14 +1414,14 @@ export const ChannelCount = () => { ### `ChannelListItem` props -| v14 | v15 | -| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lastMessage` (on `ChannelListItemUIProps` and `ChannelListItemTimestamp`) | `previewedMessage`. `ChannelListItem` also takes `previewedMessage` to preview a specific message (a search result previewing the match); it defaults to `channel.messagePaginator.aggregateState.lastMessage`. | -| `latestMessagePreview` (`ChannelListItemUIProps`), `getLatestMessagePreview` (`ChannelListItem` and `ChannelList` props) | Render ``, or override the `SummarizedMessagePreview` slot with `WithComponents` (`SummarizedMessagePreviewProps`: `{ latestMessage, messageDeliveryStatus, participantCount }`). | -| `setActiveChannel`, `watchers` | `useWorkspaceNavigation().openChannel(channel, { event })`, or the `onSelect` prop ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | -| `channelUpdateCount`, `key` | None; the item subscribes to the channel's stores | -| `active` | Unchanged prop; it now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. `ChannelListItem` passes the resolved value to `ChannelListItemUI` as `active`. The SDK's CSS highlights `.str-chat__channel-list-item[aria-selected=true]` only (v14 also matched `[aria-pressed=true]`), so a custom row that keeps the SDK class sets `role="option"` and `aria-selected={active}` | -| `activeChannel` | `active`. v14 forwarded `activeChannel` to `ChannelListItemUI` but computed the default `active` from `useChatContext().channel`. A custom row that compared `props.activeChannel` with its `channel` reads `active`; one that needs the open channel itself reads `useWorkspaceNavigation().openChannels` | +| v14 | v15 | +| ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lastMessage` (on `ChannelListItemUIProps` and `ChannelListItemTimestamp`) | `previewedMessage`. `ChannelListItem` also takes `previewedMessage` to preview a specific message (a search result previewing the match); it defaults to `channel.messagePaginator.aggregateState.lastMessage`. | +| `latestMessagePreview` (`ChannelListItemUIProps`), `getLatestMessagePreview` (`ChannelListItem` and `ChannelList` props) | Render ``, or override the `SummarizedMessagePreview` slot with `WithComponents` (`SummarizedMessagePreviewProps`: `{ latestMessage, messageDeliveryStatus, participantCount }`). | +| `setActiveChannel`, `watchers` | `useWorkspaceNavigation().openChannel(channel, { event })`, or the `onSelect` prop ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | +| `channelUpdateCount`, `key` | None; the item subscribes to the channel's stores | +| `active` | Unchanged prop; it now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. `ChannelListItem` passes the resolved value to `ChannelListItemUI` as `active`. The SDK's CSS highlights `.str-chat__channel-list-item[aria-selected=true]` only (v14 also matched `[aria-pressed=true]`), so a custom row that keeps the SDK class sets `role="option"` and `aria-selected={active}` | +| `activeChannel` | `active`. v14 forwarded `activeChannel` to `ChannelListItemUI` but computed the default `active` from `useChatContext().channel`. `active` defaults to whether the channel is open in the workspace. If the app tracks its own selection, keep the selected channel in your own context and compare it with the row's `channel`, or pass that comparison to `ChannelListItem` as `active`. A row that needs the open channels reads `useWorkspaceNavigation().openChannels` | The `getLatestMessagePreview` and `getLatestMessagePreviewText` utilities are still exported; only their `t` / `userLanguage` parameter types changed. Prefer `WithComponents` for the slot override: it merges over the parent context, while `ComponentProvider` replaces it. Neither `Chat` nor `Channel` takes component props. diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index 6051bb097e..14a77dc381 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -1792,7 +1792,7 @@ const { paginator } = useChannelListContext(); - `setActiveChannel` / `watchers` -> `useWorkspaceNavigation().openChannel(channel, { event })` or the `onSelect` prop (BC-013). - `channelUpdateCount` / `key`: none needed; the item subscribes to the channel's stores. - `active` now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. - - `activeChannel`: v14 ignored the prop too. The item compared `useChatContext().channel` (BC-013), and `ChannelList` passed `activeChannel` only for `ChannelListItemUI` to read. A custom `ChannelListItemUI` that compared it with its `channel` reads `active` instead; one that needs the open channel itself reads `useWorkspaceNavigation().openChannels`. + - `activeChannel`: v14's `ChannelListItem` did not use it for `active` (it compared `useChatContext().channel`, BC-013); it only forwarded it to `ChannelListItemUI`. A custom `ChannelListItemUI` that compared it with its `channel` reads `active` instead, which defaults to whether the channel is open in the workspace. An app that tracks its own selection, which can differ from that, keeps the selected channel in its own context and compares it with the row's `channel`, or passes that comparison to `ChannelListItem` as `active`. A row that needs the open channels reads `useWorkspaceNavigation().openChannels`. - Evidence: - `release-v14:src/components/ChannelListItem/ChannelListItem.tsx:37,39,56,58,62,68,72,74`, `:92` (`channel: activeChannel` from `useChatContext`); `release-v14:src/components/ChannelListItem/ChannelListItemTimestamp.tsx:9,12` - `master:src/components/ChannelListItem/ChannelListItem.tsx:22-65,88-109`; `master:src/components/ChannelListItem/ChannelListItemTimestamp.tsx:10,14`; `master:src/components/ChannelListItem/ChannelListItemUI.tsx:95-102,152,160-161`; `master:src/components/ChannelListItem/utils.tsx:260,324`; `master:src/context/ComponentContext.tsx:143` diff --git a/src/utils/__tests__/useSameValuesReference.test.ts b/src/utils/__tests__/useSameValuesReference.test.ts index 87affc076b..a20494f818 100644 --- a/src/utils/__tests__/useSameValuesReference.test.ts +++ b/src/utils/__tests__/useSameValuesReference.test.ts @@ -47,6 +47,15 @@ describe('useSameValuesReference', () => { expect(result.current).toBe(longer); }); + it('tells arrays of different lengths apart, sparse ones included', () => { + const { rerender, result } = renderValue([]); + + const sparse = new Array(2); + rerender({ value: sparse }); + + expect(result.current).toBe(sparse); + }); + it('compares functions by identity', () => { const fn = () => null; const { rerender, result } = renderValue({ fn }); diff --git a/src/utils/useSameValuesReference.ts b/src/utils/useSameValuesReference.ts index e86eba9821..14902e0a39 100644 --- a/src/utils/useSameValuesReference.ts +++ b/src/utils/useSameValuesReference.ts @@ -18,6 +18,7 @@ const hasSameValues = ( ): boolean => { if (Object.is(a, b)) return true; const bothArrays = Array.isArray(a) && Array.isArray(b); + if (bothArrays && a.length !== b.length) return false; if (!bothArrays && (!isPlainObject(a) || !isPlainObject(b))) return false; const left = a as Record; From f51f56fe6fae5278195a6bc330fe757c479b1843 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Nov 2026 10:08:10 +0100 Subject: [PATCH 5/5] docs: say what custom code loses with emojiSearchIndex and activeChannel A custom component that read emojiSearchIndex or parent from the composer context gets undefined and needs the value from its own props or context. ChannelListItem active is true only while the channel is shown, and openChannels lists the slot-bound channels, so neither is a full list of what the user sees. Ticket: REACT-1277 --- ai-docs/ai-migration-v14-v15.md | 18 +++++++++--------- ai-docs/v15-breaking-changes.md | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index b73d98b084..c7fca3aaeb 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1416,14 +1416,14 @@ export const ChannelCount = () => { ### `ChannelListItem` props -| v14 | v15 | -| ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lastMessage` (on `ChannelListItemUIProps` and `ChannelListItemTimestamp`) | `previewedMessage`. `ChannelListItem` also takes `previewedMessage` to preview a specific message (a search result previewing the match); it defaults to `channel.messagePaginator.aggregateState.lastMessage`. | -| `latestMessagePreview` (`ChannelListItemUIProps`), `getLatestMessagePreview` (`ChannelListItem` and `ChannelList` props) | Render ``, or override the `SummarizedMessagePreview` slot with `WithComponents` (`SummarizedMessagePreviewProps`: `{ latestMessage, messageDeliveryStatus, participantCount }`). | -| `setActiveChannel`, `watchers` | `useWorkspaceNavigation().openChannel(channel, { event })`, or the `onSelect` prop ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | -| `channelUpdateCount`, `key` | None; the item subscribes to the channel's stores | -| `active` | Unchanged prop; it now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. `ChannelListItem` passes the resolved value to `ChannelListItemUI` as `active`. The SDK's CSS highlights `.str-chat__channel-list-item[aria-selected=true]` only (v14 also matched `[aria-pressed=true]`), so a custom row that keeps the SDK class sets `role="option"` and `aria-selected={active}` | -| `activeChannel` | `active`. v14 forwarded `activeChannel` to `ChannelListItemUI` but computed the default `active` from `useChatContext().channel`. `active` defaults to whether the channel is open in the workspace. If the app tracks its own selection, keep the selected channel in your own context and compare it with the row's `channel`, or pass that comparison to `ChannelListItem` as `active`. A row that needs the open channels reads `useWorkspaceNavigation().openChannels` | +| v14 | v15 | +| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lastMessage` (on `ChannelListItemUIProps` and `ChannelListItemTimestamp`) | `previewedMessage`. `ChannelListItem` also takes `previewedMessage` to preview a specific message (a search result previewing the match); it defaults to `channel.messagePaginator.aggregateState.lastMessage`. | +| `latestMessagePreview` (`ChannelListItemUIProps`), `getLatestMessagePreview` (`ChannelListItem` and `ChannelList` props) | Render ``, or override the `SummarizedMessagePreview` slot with `WithComponents` (`SummarizedMessagePreviewProps`: `{ latestMessage, messageDeliveryStatus, participantCount }`). | +| `setActiveChannel`, `watchers` | `useWorkspaceNavigation().openChannel(channel, { event })`, or the `onSelect` prop ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | +| `channelUpdateCount`, `key` | None; the item subscribes to the channel's stores | +| `active` | Unchanged prop; it now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. `ChannelListItem` passes the resolved value to `ChannelListItemUI` as `active`. The SDK's CSS highlights `.str-chat__channel-list-item[aria-selected=true]` only (v14 also matched `[aria-pressed=true]`), so a custom row that keeps the SDK class sets `role="option"` and `aria-selected={active}` | +| `activeChannel` | `active`. v14 forwarded `activeChannel` to `ChannelListItemUI` but computed the default `active` from `useChatContext().channel`. `active` defaults to whether the channel is open in the workspace. If the app tracks its own selection, keep the selected channel in your own context and compare it with the row's `channel`, or pass that comparison to `ChannelListItem` as `active`. A row that needs the open channels reads `useWorkspaceNavigation().openChannels`, which lists the channels bound to the slots: it includes a channel covered by a stacked panel (for which `active` is false) and leaves out one opened in a stacked panel | The `getLatestMessagePreview` and `getLatestMessagePreviewText` utilities are still exported; only their `t` / `userLanguage` parameter types changed. Prefer `WithComponents` for the slot override: it merges over the parent context, while `ComponentProvider` replaces it. Neither `Chat` nor `Channel` takes component props. @@ -2903,7 +2903,7 @@ Only the four built-in action types (`uploadFile`, `createPoll`, `addLocation`, ### `emojiSearchIndex` and `parent` are removed from `MessageComposer` -`MessageComposer` no longer takes `emojiSearchIndex` or `parent`, the `emojiSearchIndex` override key is removed, and `useMessageComposerContext()` no longer returns `emojiSearchIndex`, `parent` or `audioRecordingConfig`. In v14 they only reached the composer context, and nothing read them there, so they turned on nothing: drop them. Emoji autocomplete comes from a text composer middleware. Register it only if the app wants `:shortcode` suggestions, which v14 did not show from these props. `client.config.setSetupFunction()` replaces the previous setup function for the key, so if the app already registers one for `messageComposer`, add the `insert` call to it and keep its teardown: +`MessageComposer` no longer takes `emojiSearchIndex` or `parent`, the `emojiSearchIndex` override key is removed, and `useMessageComposerContext()` no longer returns `emojiSearchIndex`, `parent` or `audioRecordingConfig`. In v14 they only reached the composer context, and nothing in the SDK read them there, so they turned on nothing: drop them. A custom component that read `emojiSearchIndex` or `parent` from `useMessageComposerContext()` gets `undefined`; give it the value through its own props or context. Emoji autocomplete comes from a text composer middleware. Register it only if the app wants `:shortcode` suggestions, which v14 did not show from these props. `client.config.setSetupFunction()` replaces the previous setup function for the key, so if the app already registers one for `messageComposer`, add the `insert` call to it and keep its teardown: ```tsx // v14 diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index 4540b18306..7c08195915 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -1793,7 +1793,7 @@ const { paginator } = useChannelListContext(); - `setActiveChannel` / `watchers` -> `useWorkspaceNavigation().openChannel(channel, { event })` or the `onSelect` prop (BC-013). - `channelUpdateCount` / `key`: none needed; the item subscribes to the channel's stores. - `active` now defaults to `useWorkspaceNavigation().isChannelActive(cid)`. - - `activeChannel`: v14's `ChannelListItem` did not use it for `active` (it compared `useChatContext().channel`, BC-013); it only forwarded it to `ChannelListItemUI`. A custom `ChannelListItemUI` that compared it with its `channel` reads `active` instead, which defaults to whether the channel is open in the workspace. An app that tracks its own selection, which can differ from that, keeps the selected channel in its own context and compares it with the row's `channel`, or passes that comparison to `ChannelListItem` as `active`. A row that needs the open channels reads `useWorkspaceNavigation().openChannels`. + - `activeChannel`: v14's `ChannelListItem` did not use it for `active` (it compared `useChatContext().channel`, BC-013); it only forwarded it to `ChannelListItemUI`. A custom `ChannelListItemUI` that compared it with its `channel` reads `active` instead, which defaults to whether the channel is open in the workspace. An app that tracks its own selection, which can differ from that, keeps the selected channel in its own context and compares it with the row's `channel`, or passes that comparison to `ChannelListItem` as `active`. A row that needs the open channels reads `useWorkspaceNavigation().openChannels`. `active` is true while the channel is shown: a panel stacked over it makes it false. `openChannels` lists the channels bound to the workspace slots, so it includes a covered channel and leaves out one opened in a stacked panel; neither is a full list of what the user sees. - Evidence: - `release-v14:src/components/ChannelListItem/ChannelListItem.tsx:37,39,56,58,62,68,72,74`, `:92` (`channel: activeChannel` from `useChatContext`); `release-v14:src/components/ChannelListItem/ChannelListItemTimestamp.tsx:9,12` - `master:src/components/ChannelListItem/ChannelListItem.tsx:22-65,88-109`; `master:src/components/ChannelListItem/ChannelListItemTimestamp.tsx:10,14`; `master:src/components/ChannelListItem/ChannelListItemUI.tsx:95-102,152,160-161`; `master:src/components/ChannelListItem/utils.tsx:260,324`; `master:src/context/ComponentContext.tsx:143` @@ -3571,7 +3571,7 @@ import { - `ComponentContextValue.emojiSearchIndex` - `MessageComposerContextValue.emojiSearchIndex`, `.parent`, `.audioRecordingConfig` (the last was typed but never set) - Replacement: - - drop the props: they turned on nothing in v14. + - drop the props: they turned on nothing in v14. A custom component that read `emojiSearchIndex` or `parent` from `useMessageComposerContext()` gets `undefined`; give it the value through its own props or context. - emoji autocomplete, only if the app wants `:shortcode` suggestions (v14 showed none from these props): initialize emoji-mart (`init({ data })`), then register `createTextComposerEmojiMiddleware(SearchIndex)` from `stream-chat-react/emojis` on every composer with `client.config.setSetupFunction('messageComposer', ...)`. That call replaces a previously registered setup function for the key, so an app that already has one adds the `insert` call to it. The `EmojiSearchIndex` type is still exported. - `parent`: the composer belongs to the thread already. Where you need the parent message, subscribe with `useStateStore(useThreadContext()?.state, selector)` and read `parentMessage`. - `audioRecordingConfig` stays a `MessageComposer` prop; the recorder reads it, the context does not carry it.