Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 38 additions & 13 deletions ai-docs/ai-migration-v14-v15.md

Large diffs are not rendered by default.

42 changes: 41 additions & 1 deletion ai-docs/v15-breaking-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ yarn api-surface check --doc ai-docs/v15-breaking-changes.md --diff diff.json

## Index

95 entries.
96 entries.

| ID | Title | Group |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
Expand Down Expand Up @@ -163,6 +163,7 @@ yarn api-surface check --doc ai-docs/v15-breaking-changes.md --diff diff.json
| [BC-093](#bc-093) | `MessageContextValue.onMentionsClickMessage` / `onMentionsHoverMessage` and `useMentionsHandler`'s handlers are `undefined` without a handler | Messages and message lists |
| [BC-094](#bc-094) | `mentionsMarkdownPlugin` and `createMentionPluginAndDisplayTextSet` take mention entities only | Messages and message lists |
| [BC-095](#bc-095) | `disableQuotedMessages` is removed from `MessageProps`, `MessageListProps` and `useMessagePermissions` | Messages and message lists |
| [BC-102](#bc-102) | `initialMessage` is removed from `MessageProps` and `MessageContextValue`; `useIsRenderedAsThreadParent()` tells the thread's parent message apart | Messages and message lists |

## Package and runtime

Expand Down Expand Up @@ -3098,6 +3099,44 @@ export const List = () => (
);
```

<a id="bc-102"></a>

### 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 `<Message {...props} />` 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 `<Message initialMessage threadList {...props} />`.
- `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 `<Message>` renders: it reads `useMessageContext()`, which throws outside a `MessageProvider`. A `ThreadHead` override renders `<Message {...props} />`.
- 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

<a id="bc-060"></a>
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MessageUI,
useChannel,
useChatContext,
useIsRenderedAsThreadParent,
useMessageContext,
useStateStore,
useThreadContext,
Expand All @@ -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,
Expand Down
1 change: 0 additions & 1 deletion src/components/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
1 change: 0 additions & 1 deletion src/components/Message/__tests__/MessageText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ const bob = generateUser({ name: 'bob' });
const onMentionsHoverMock = vi.fn();
const onMentionsClickMock = vi.fn();
const defaultProps = {
initialMessage: false,
message: generateMessage(),
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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) => (
<ThreadProvider thread={thread}>
<MessageProvider value={mockMessageContext({ message })}>
{children}
</MessageProvider>
</ThreadProvider>
);
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);
});
});
1 change: 1 addition & 0 deletions src/components/Message/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
14 changes: 14 additions & 0 deletions src/components/Message/hooks/useIsRenderedAsThreadParent.ts
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 0 additions & 2 deletions src/components/Message/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 36 additions & 16 deletions src/components/MessageActions/__tests__/MessageActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -83,6 +84,7 @@ async function renderMessageActions({
customChatContext = {},
customMessageContext = {},
messageActionsProps = {},
threadParentMessage,
}: any = {}) {
const client = chatClient || (await getTestClientWithUser(alice));
if (customChatContext.mutes) {
Expand All @@ -99,19 +101,25 @@ async function renderMessageActions({
},
});

const thread = threadParentMessage
? client.threads.ensure({ channel, parentMessage: threadParentMessage })
: undefined;

return render(
<Chat client={client}>
<Channel channel={channel}>
<DialogManagerProvider id='message-actions-dialog-provider'>
<MessageProvider
value={mockMessageContext({
...defaultMessageContextValue,
...customMessageContext,
})}
>
<MessageActions {...messageActionsProps} />
</MessageProvider>
</DialogManagerProvider>
<ThreadProvider thread={thread}>
<DialogManagerProvider id='message-actions-dialog-provider'>
<MessageProvider
value={mockMessageContext({
...defaultMessageContextValue,
...customMessageContext,
})}
>
<MessageActions {...messageActionsProps} />
</MessageProvider>
</DialogManagerProvider>
</ThreadProvider>
</Channel>
</Chat>,
);
Expand Down Expand Up @@ -154,13 +162,25 @@ describe('<MessageActions />', () => {
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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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' ||
Expand Down Expand Up @@ -142,7 +143,7 @@ export const useBaseMessageActionSetFilter = (
canSendMessage,
userMessageRemindersEnabled,
isBounced,
isInitialMessage,
isThreadParent,
messageIsDeleted,
isMessageThreadReply,
isMyMessage,
Expand Down
26 changes: 26 additions & 0 deletions src/components/MessageList/__tests__/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => (
<div data-testid='custom-thread-head'>
<Message {...props} />
</div>
);

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>(() => (
<div />
Expand Down
1 change: 0 additions & 1 deletion src/components/MessageList/renderMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ type MessagePropsToOmit =
| 'channel'
| 'deliveredTo'
| 'groupStyles'
| 'initialMessage'
| 'lastReceivedId'
| 'message'
| 'readBy';
Expand Down
2 changes: 1 addition & 1 deletion src/components/Thread/ThreadHead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const ThreadHead = (props: MessageProps) => {
return (
<div className='str-chat__parent-message-li'>
{parentCreatedAt ? <DateSeparator date={parentCreatedAt} /> : null}
<Message initialMessage {...props} />
<Message {...props} />
<ThreadStart />
</div>
);
Expand Down
2 changes: 0 additions & 2 deletions src/context/MessageContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading