From 219164688579eb2d9b45d39e360e68ebd8552c6e Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Nov 2026 14:18:23 +0100 Subject: [PATCH] refactor(Message)!: replace initialMessage with useIsRenderedAsThreadParent Add useIsRenderedAsThreadParent(), which tells whether the message in MessageContext is the parent message of the thread in ThreadContext (a Thread's id is its parent message's id). The base message action filter uses it, so the thread's parent message gets no actions even when a custom ThreadHead renders without a flag. BREAKING CHANGE: MessageProps.initialMessage and MessageContextValue.initialMessage are removed, and ThreadHead no longer sets the flag. Read useIsRenderedAsThreadParent() instead. See BC-102 in ai-docs/v15-breaking-changes.md and "initialMessage is removed; use useIsRenderedAsThreadParent()" in ai-docs/ai-migration-v14-v15.md. --- ai-docs/ai-migration-v14-v15.md | 51 ++++++++++++---- ai-docs/v15-breaking-changes.md | 42 ++++++++++++- .../ReadReceiptAvatars.tsx | 6 +- src/components/Message/Message.tsx | 1 - .../Message/__tests__/MessageText.test.tsx | 1 - .../useIsRenderedAsThreadParent.test.tsx | 59 +++++++++++++++++++ src/components/Message/hooks/index.ts | 1 + .../hooks/useIsRenderedAsThreadParent.ts | 14 +++++ src/components/Message/types.ts | 2 - .../__tests__/MessageActions.test.tsx | 52 +++++++++++----- .../hooks/useBaseMessageActionSetFilter.ts | 13 ++-- .../__tests__/MessageList.test.tsx | 26 ++++++++ src/components/MessageList/renderMessages.tsx | 1 - src/components/Thread/ThreadHead.tsx | 2 +- src/context/MessageContext.tsx | 2 - 15 files changed, 227 insertions(+), 46 deletions(-) create mode 100644 src/components/Message/hooks/__tests__/useIsRenderedAsThreadParent.test.tsx create mode 100644 src/components/Message/hooks/useIsRenderedAsThreadParent.ts diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 38937f17a0..c34c07f20b 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -149,7 +149,7 @@ code '\bMessage=\{|\b(Message|ThreadHeader|HeaderStartContent|ChannelListUI|Load # §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 -code '`). The default `ThreadHead` is not exported, so an override renders the whole head (`DateSeparator` for the parent's `created_at`, `` and `ThreadStart`) | -| `autoFocus` (default `true`) | `` in every thread that should focus, whether or not you passed `autoFocus` | -| `enableDateSeparator` | `withDateSeparator` on the list | -| `Message` | A `MessageUI` override scoped with `WithComponents` | -| `messageActions` | Removed ([section 10](#messageactions-and-message_actions-are-removed)) | -| `virtualized` | Render the list you want | +| Removed `ThreadProps` | v15 | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `additionalMessageComposerProps` | Props on your `MessageComposer` child | +| `additionalMessageListProps` | Props on your `MessageList` child | +| `additionalVirtualizedMessageListProps` | Props on your `VirtualizedMessageList` child | +| `additionalParentMessageProps` | The list's own message props, which reach the parent message too; for anything else, a `ThreadHead` override on `ComponentContext` (typed `ComponentType`). The default `ThreadHead` is not exported, so an override renders the whole head (`DateSeparator` for the parent's `created_at`, `` and `ThreadStart`) | +| `autoFocus` (default `true`) | `` in every thread that should focus, whether or not you passed `autoFocus` | +| `enableDateSeparator` | `withDateSeparator` on the list | +| `Message` | A `MessageUI` override scoped with `WithComponents` | +| `messageActions` | Removed ([section 10](#messageactions-and-message_actions-are-removed)) | +| `virtualized` | Render the list you want | **Defaults changed.** A `MessageList` inside a thread now shows date separators unless you pass `withDateSeparator={false}` (v14's `Thread` turned them off). `autoFocus` defaulted to `true`, so every v14 thread focused its composer on open, not only threads that passed the prop; in v15 the composer is focused only if you render ``. @@ -2439,6 +2441,28 @@ export const List = () => ( ); ``` +### `initialMessage` is removed; use `useIsRenderedAsThreadParent()` + +`Message` no longer takes `initialMessage`, and `MessageContextValue` no longer has it. The SDK tells the thread's parent message from its replies itself: `useIsRenderedAsThreadParent()` returns whether the message in `MessageContext` is the parent message of the thread in `ThreadContext`, because a `stream-chat` `Thread`'s `id` is its parent message's id. It returns `false` for the thread's replies and outside a thread. Call it in a component that `` renders, such as a `MessageUI` override. + +- Passing `initialMessage` or reading `useMessageContext().initialMessage` is a compile error. In untyped code the prop is ignored and the field is `undefined`, so a check such as `!!useThreadContext() && !initialMessage` takes the parent for a reply. +- The parent message still gets no message actions, and now gets none without the flag too. A `ThreadHead` override renders ``. + +For example, a component under each message that should skip the thread's replies but not its parent: + +```tsx +// v14 +const { initialMessage } = useMessageContext(); +const isThreadReply = !!useThreadContext() && !initialMessage; +``` + +```tsx +import { useIsRenderedAsThreadParent, useThreadContext } from 'stream-chat-react'; + +const isThreadParent = useIsRenderedAsThreadParent(); +const isThreadReply = !!useThreadContext() && !isThreadParent; +``` + ### `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. @@ -3437,7 +3461,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| ( ); ``` + + +### BC-102: `initialMessage` is removed from `MessageProps` and `MessageContextValue`; `useIsRenderedAsThreadParent()` tells the thread's parent message apart + +- Status: confirmed +- Area: message / thread +- User impact: + - compile error on passing `initialMessage` to `Message` or reading `useMessageContext().initialMessage`. + - silent behavior change in untyped code: the prop is ignored and the context field is `undefined`, so code that read it to tell the thread's parent message from its replies takes the parent for a reply. For example, `!!useThreadContext() && !initialMessage` is `true` for the parent. + - The default message actions still give the thread's parent message no actions, and now detect it themselves. A `ThreadHead` override that renders `` without `initialMessage` no longer gives the parent the full action menu, as it did in v14. +- Old API: + - `MessageProps.initialMessage?: boolean`, passed on as `MessageContextValue.initialMessage`. The default `ThreadHead` rendered ``. + - `useBaseMessageActionSetFilter` returned no actions while `initialMessage` was set. +- Replacement: `useIsRenderedAsThreadParent()`, exported from `stream-chat-react`. It returns whether the message in `MessageContext` is the parent message of the thread in `ThreadContext`: a `stream-chat` `Thread`'s `id` is its parent message's id. It returns `false` for the thread's replies and outside a thread. Call it in a component that `` renders: it reads `useMessageContext()`, which throws outside a `MessageProvider`. A `ThreadHead` override renders ``. +- Evidence: + - `release-v14:src/components/Message/types.ts:36`, `release-v14:src/context/MessageContext.tsx:90`, `release-v14:src/components/Message/Message.tsx:255`, `release-v14:src/components/Thread/ThreadHead.tsx:15`, `release-v14:src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts:32` + - `master:src/components/Message/hooks/useIsRenderedAsThreadParent.ts`, `master:src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts` (`isThreadParent`), `master:src/components/Thread/ThreadHead.tsx` + - commit: REACT-1521 +- Docs impact: + - `06-component-reference/04-message/01-message.md` (`initialMessage` prop), `06-component-reference/04-message/02-messagecontext.md` (`initialMessage` field), `07-reference/02-hooks/10-message-hooks.md` (the new hook; `useBaseMessageActionSetFilter`), `05-ui-cookbooks/04-message-list/06-read-receipt-avatars.md`, `08-release-guides/01-upgrade-to-v15.md` +- Example needed: yes + +Before (v14): + +```tsx +const { initialMessage } = useMessageContext(); +const isThreadReply = !!useThreadContext() && !initialMessage; +``` + +After (v15): + +```tsx +import { useIsRenderedAsThreadParent, useThreadContext } from 'stream-chat-react'; + +const isThreadParent = useIsRenderedAsThreadParent(); +const isThreadReply = !!useThreadContext() && !isThreadParent; +``` + ## Message composer @@ -4325,6 +4364,7 @@ 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.initialMessage`, `MessageProps.initialMessage`, `MessageContextValue.initialMessage` removed, `useIsRenderedAsThreadParent` added -> BC-102 - components/MessageList - `MessageList.disableQuotedMessages`, `MessageListProps.disableQuotedMessages`, `SharedMessageProps.disableQuotedMessages` -> BC-095 - `getIsFirstUnreadMessage` -> BC-054 diff --git a/examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.tsx b/examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.tsx index ae15c56e6c..16baa89873 100644 --- a/examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.tsx +++ b/examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.tsx @@ -5,6 +5,7 @@ import { MessageUI, useChannel, useChatContext, + useIsRenderedAsThreadParent, useMessageContext, useStateStore, useThreadContext, @@ -21,8 +22,9 @@ const readersSelector = ({ readersByMessageId }: MessageReceiptsSnapshot) => ({ const ReadReceiptAvatars = () => { const channel = useChannel(); const { client } = useChatContext(); - const { initialMessage, message } = useMessageContext(); - const isThreadReply = !!useThreadContext() && !initialMessage; + const { message } = useMessageContext(); + const isThreadParent = useIsRenderedAsThreadParent(); + const isThreadReply = !!useThreadContext() && !isThreadParent; const { readersByMessageId } = useStateStore( channel.messageReceiptsTracker.snapshotStore, readersSelector, diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 91dadf58df..d1f9bbc534 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -181,7 +181,6 @@ export const Message = (props: MessageProps) => { handleReaction={handleReaction} handleRetry={handleRetry} highlighted={props.highlighted} - initialMessage={props.initialMessage} lastOwnMessage={props.lastOwnMessage} lastReceivedId={props.lastReceivedId} message={message} diff --git a/src/components/Message/__tests__/MessageText.test.tsx b/src/components/Message/__tests__/MessageText.test.tsx index e8e3d6e7f6..70ad991047 100644 --- a/src/components/Message/__tests__/MessageText.test.tsx +++ b/src/components/Message/__tests__/MessageText.test.tsx @@ -62,7 +62,6 @@ const bob = generateUser({ name: 'bob' }); const onMentionsHoverMock = vi.fn(); const onMentionsClickMock = vi.fn(); const defaultProps = { - initialMessage: false, message: generateMessage(), }; diff --git a/src/components/Message/hooks/__tests__/useIsRenderedAsThreadParent.test.tsx b/src/components/Message/hooks/__tests__/useIsRenderedAsThreadParent.test.tsx new file mode 100644 index 0000000000..dabfab6f4f --- /dev/null +++ b/src/components/Message/hooks/__tests__/useIsRenderedAsThreadParent.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import type { PropsWithChildren } from 'react'; +import { renderHook } from '@testing-library/react'; +import type { LocalMessage, Thread } from 'stream-chat'; + +import { useIsRenderedAsThreadParent } from '../useIsRenderedAsThreadParent'; +import { MessageProvider } from '../../../../context'; +import { ThreadProvider } from '../../../Threads'; +import { + generateMessage, + initClientWithChannels, + mockMessageContext, +} from '../../../../mock-builders'; + +const parentMessage = generateMessage() as LocalMessage; +const reply = generateMessage({ parent_id: parentMessage.id }) as LocalMessage; + +const renderUseIsRenderedAsThreadParent = ({ + message, + thread, +}: { + message: LocalMessage; + thread?: Thread; +}) => { + const wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + return renderHook(() => useIsRenderedAsThreadParent(), { wrapper }).result.current; +}; + +describe('useIsRenderedAsThreadParent', () => { + let thread: Thread; + + beforeEach(async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + thread = client.threads.ensure({ channel, parentMessage }); + }); + + it('is true for the parent message of the thread in context', () => { + expect(renderUseIsRenderedAsThreadParent({ message: parentMessage, thread })).toBe( + true, + ); + }); + + it('is false for a reply in the thread', () => { + expect(renderUseIsRenderedAsThreadParent({ message: reply, thread })).toBe(false); + }); + + it('is false outside a thread, even for a message that has a thread', () => { + expect(renderUseIsRenderedAsThreadParent({ message: parentMessage })).toBe(false); + }); +}); diff --git a/src/components/Message/hooks/index.ts b/src/components/Message/hooks/index.ts index 1029b03f01..2d88b84cdb 100644 --- a/src/components/Message/hooks/index.ts +++ b/src/components/Message/hooks/index.ts @@ -1,6 +1,7 @@ export * from './useActionHandler'; export * from './useDeleteHandler'; export * from './useFlagHandler'; +export * from './useIsRenderedAsThreadParent'; export * from './useMentionsHandler'; export * from './useMarkUnreadHandler'; export * from './useMessageAlsoSentInChannelNavigation'; diff --git a/src/components/Message/hooks/useIsRenderedAsThreadParent.ts b/src/components/Message/hooks/useIsRenderedAsThreadParent.ts new file mode 100644 index 0000000000..7fd5f44a16 --- /dev/null +++ b/src/components/Message/hooks/useIsRenderedAsThreadParent.ts @@ -0,0 +1,14 @@ +import { useMessageContext } from '../../../context'; +import { useThreadContext } from '../../Threads'; + +/** + * Whether the message in `MessageContext` is the parent message of the thread in `ThreadContext`. + * A thread's id is its parent message's id, so inside a thread only the parent message matches. + * + * @returns True for the thread's parent message, false for its replies and outside a thread. + */ +export const useIsRenderedAsThreadParent = () => { + const thread = useThreadContext(); + const { message } = useMessageContext(); + return !!thread && thread.id === message.id; +}; diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index 667be71e88..1e6435298a 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -40,8 +40,6 @@ export type MessageProps = { groupStyles?: GroupStyle[]; /** Whether to highlight the message. The message lists set it on the focused message. */ highlighted?: boolean; - /** Whether the message is the thread's parent shown at the top of the thread. */ - initialMessage?: boolean; // todo: move to LLC Channel state /** Latest own message in the currently displayed message set. */ lastOwnMessage?: LocalMessage; diff --git a/src/components/MessageActions/__tests__/MessageActions.test.tsx b/src/components/MessageActions/__tests__/MessageActions.test.tsx index bd6141595f..c29b4231b3 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.tsx +++ b/src/components/MessageActions/__tests__/MessageActions.test.tsx @@ -27,6 +27,7 @@ import { ResizeObserverMock } from '../../../mock-builders/browser'; import { Message } from '../../Message'; import { Channel } from '../../Channel'; import { Chat } from '../../Chat'; +import { ThreadProvider } from '../../Threads'; import { convertDateToTimestamp } from '../../../mock-builders'; (window as any).ResizeObserver = ResizeObserverMock; @@ -83,6 +84,7 @@ async function renderMessageActions({ customChatContext = {}, customMessageContext = {}, messageActionsProps = {}, + threadParentMessage, }: any = {}) { const client = chatClient || (await getTestClientWithUser(alice)); if (customChatContext.mutes) { @@ -99,19 +101,25 @@ async function renderMessageActions({ }, }); + const thread = threadParentMessage + ? client.threads.ensure({ channel, parentMessage: threadParentMessage }) + : undefined; + return render( - - - - - + + + + + + + , ); @@ -154,13 +162,25 @@ describe('', () => { expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument(); }); - it('should not render when message is parent message in a thread', async () => { - const { queryByTestId } = await renderMessageActions({ - customMessageContext: { - initialMessage: true, - }, + it('should not render for the parent message of the thread it is rendered in', async () => { + const message = generateMessage({ user: alice }); + const { container } = await renderMessageActions({ + customMessageContext: { message }, + threadParentMessage: message, }); - expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument(); + expect( + container.querySelector('.str-chat__message-options'), + ).not.toBeInTheDocument(); + }); + + it('should render for a reply in a thread', async () => { + const parentMessage = generateMessage({ user: alice }); + const message = generateMessage({ parent_id: parentMessage.id, user: alice }); + const { container } = await renderMessageActions({ + customMessageContext: { message }, + threadParentMessage: parentMessage, + }); + expect(container.querySelector('.str-chat__message-options')).toBeInTheDocument(); }); it('should render correctly when not open', async () => { diff --git a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts index 2d84966a5e..a19e813d21 100644 --- a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts +++ b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useChannel, useMessageContext } from '../../../context'; -import { useMessagePermissions } from '../../Message/hooks'; +import { useIsRenderedAsThreadParent, useMessagePermissions } from '../../Message/hooks'; import { ACTIONS_NOT_WORKING_IN_THREAD, isMessageBounced, @@ -29,15 +29,16 @@ const userMessageRemindersStateSelector = ({ userMessageReminders }: ChannelConf * @param messageActionSet - Actions to filter. * @param disable - Skips the checks above when true, leaving only each action's `isVisible`. * Defaults to false. - * @returns The actions that apply to the message, none for bounced, initial, system, ephemeral, - * sending or typeless messages. + * @returns The actions that apply to the message, none for bounced, system, ephemeral, sending or + * typeless messages, or for the parent message of the thread it is rendered in. */ export const useBaseMessageActionSetFilter = ( messageActionSet: MessageActionSetItem[], disable = false, ) => { const channel = useChannel(); - const { initialMessage: isInitialMessage, message } = useMessageContext(); + const { message } = useMessageContext(); + const isThreadParent = useIsRenderedAsThreadParent(); const { userMessageRemindersEnabled } = useStateStore( channel.configState, userMessageRemindersStateSelector, @@ -78,7 +79,7 @@ export const useBaseMessageActionSetFilter = ( // These messages get no actions. if ( isBounced || - isInitialMessage || // the thread parent that `ThreadHead` renders above the replies + isThreadParent || !message.type || message.type === 'system' || message.type === 'ephemeral' || @@ -142,7 +143,7 @@ export const useBaseMessageActionSetFilter = ( canSendMessage, userMessageRemindersEnabled, isBounced, - isInitialMessage, + isThreadParent, messageIsDeleted, isMessageThreadReply, isMyMessage, diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index d9c121cf10..8621734d54 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -27,6 +27,7 @@ import { import { Chat } from '../../Chat'; import { MessageList } from '../MessageList'; import { Channel } from '../../Channel'; +import { Message } from '../../Message'; import { CHANNEL_CONTAINER_ID } from '../../Channel/constants'; import { MESSAGE_LIST_MAIN_PANEL_CLASS } from '../MessageListMainPanel'; import { ThreadProvider } from '../../Threads'; @@ -394,6 +395,31 @@ describe('MessageList', () => { }); }); + it('should give no actions to the parent message that a ThreadHead override renders', async () => { + const thread = chatClient.threads.ensure({ channel, parentMessage: message1 }); + const CustomThreadHead = (props: MessageProps) => ( +
+ +
+ ); + + await act(() => { + renderComponent({ + channelProps: { channel }, + chatClient, + components: { ThreadHead: CustomThreadHead }, + msgListProps: { messages: [] }, + thread, + }); + }); + + const threadHead = await screen.findByTestId('custom-thread-head'); + expect(within(threadHead).getByText(message1.text)).toBeInTheDocument(); + expect( + threadHead.querySelector('.str-chat__message-options'), + ).not.toBeInTheDocument(); + }); + it('should give the thread parent message the message props of its replies', async () => { const CustomThreadHead = vi.fn<(props: MessageProps) => React.ReactElement>(() => (
diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx index dd4420928d..d22ca53b0e 100644 --- a/src/components/MessageList/renderMessages.tsx +++ b/src/components/MessageList/renderMessages.tsx @@ -46,7 +46,6 @@ type MessagePropsToOmit = | 'channel' | 'deliveredTo' | 'groupStyles' - | 'initialMessage' | 'lastReceivedId' | 'message' | 'readBy'; diff --git a/src/components/Thread/ThreadHead.tsx b/src/components/Thread/ThreadHead.tsx index 8e2d60a984..7ec23299ae 100644 --- a/src/components/Thread/ThreadHead.tsx +++ b/src/components/Thread/ThreadHead.tsx @@ -14,7 +14,7 @@ export const ThreadHead = (props: MessageProps) => { return (
{parentCreatedAt ? : null} - +
); diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index 02aa5209a8..a309537a68 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -69,8 +69,6 @@ export type MessageContextValue = { groupStyles?: GroupStyle[]; /** Whether to highlight the message. The message lists set it on the focused message. */ highlighted?: boolean; - /** Whether the message is the thread's parent shown at the top of the thread. */ - initialMessage?: boolean; /** Whether a message is AI generated. */ isMessageAIGenerated?: (message: LocalMessage) => boolean; /** Latest own message in the currently displayed message set. */