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
117 changes: 117 additions & 0 deletions ai-docs/ai-migration-v14-v15.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,120 @@ dictionary: [`i18n-v15-migration.md`](./i18n-v15-migration.md).
- **Customize how the preview renders** → provide a `SummarizedMessagePreview` component override (via `ComponentProvider`, or the `<Chat>` / `<Channel>` component props). It receives `SummarizedMessagePreviewProps` (`{ latestMessage, messageDeliveryStatus, participantCount }`).
- **Preview a specific message** rather than the channel's latest (e.g. a search result previewing the matched message) → pass the new `previewedMessage?: LocalMessage` prop to `ChannelListItem`. It defaults to the channel's reactive latest, `channel.messagePaginator.aggregateState.lastMessage`.
- **Behavior note:** the preview now honors the channel's `skip_last_msg_update_for_system_msgs` config (a system message no longer becomes the previewed / last message), so the preview and the channel's sort position agree.

## Message UI overrides consolidate on the `MessageUI` slot

The deprecated `Message` component override is removed from `ComponentContext`, and so is every `Message` **prop** that took a message UI component. There is now exactly one way to override the message UI — the `MessageUI` slot — plus `VirtualMessage` for the virtualized list.

| v14 | v15 |
| -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `ComponentContext.Message` (deprecated in v14) | `ComponentContext.MessageUI` |
| `<Message Message={Custom} />` | `MessageUI` override |
| `<MessageList Message={Custom} />` | `MessageUI` override |
| `<VirtualizedMessageList Message={Custom} />` | `VirtualMessage` override (falls back to `MessageUI`) |
| `<Thread Message={Custom} />` | `MessageUI` override, scoped with `WithComponents` (see below) |
| `additionalMessageListProps` / `additionalVirtualizedMessageListProps` / `additionalParentMessageProps` carrying `Message` | `MessageUI` override |

```tsx
// before
<WithComponents overrides={{ Message: CustomMessage }}>
<Channel channel={channel}>
<MessageList Message={CustomMessage} />
</Channel>
</WithComponents>;

// after
<WithComponents overrides={{ MessageUI: CustomMessage }}>
<Channel channel={channel}>
<MessageList />
</Channel>
</WithComponents>;
```

**`VirtualMessage` still wins inside the virtualized list**, and only there — `VirtualizedMessageList` applies it to its own subtree's `ComponentContext`, so it no longer needs to be drilled through a prop and no longer affects messages rendered outside the list:

```tsx
<WithComponents
overrides={{ MessageUI: CustomMessage, VirtualMessage: CustomVirtualMessage }}
>
<Channel channel={channel}>
<VirtualizedMessageList /> {/* renders CustomVirtualMessage */}
<MessageList /> {/* renders CustomMessage */}
</Channel>
</WithComponents>
```

**Overriding only a thread's messages** (was `<Thread Message={…} />`) means scoping the slot to the thread's subtree:

```tsx
<WithComponents overrides={{ MessageUI: CustomThreadMessage }}>
<Thread />
</WithComponents>
```

> **This can fail silently in untyped code.** In TypeScript every leftover is a compile error: `Message={…}` on `Message` / `MessageList` / `VirtualizedMessageList` / `Thread` fails with `TS2322`, and a stale `Message:` key in a `WithComponents` `overrides` object or in `additionalMessageListProps` fails with `TS2561` — which reports `Did you mean to write 'MessageUI'?`, so the compiler names the fix. In plain JS or behind an `any`, both are ignored with no warning and the **default** message UI renders. Grep for `Message=` and `Message:` rather than relying on the build to find them.

A custom message UI receives no props — read everything from `useMessageContext()`.

## Context hooks: no `componentName` argument, and required contexts throw

Two changes. The first is a compile break; the second only surfaces at runtime.

### The optional `componentName` argument is removed

```tsx
// v14
const { t } = useTranslationContext('EmojiPicker');
const { textareaRef } = useMessageComposerContext('EmojiPicker');

// v15
const { t } = useTranslationContext();
const { textareaRef } = useMessageComposerContext();
```

Affects `useChatContext`, `useTranslationContext`, `useMessageContext`, `useComponentContext`,
`useMessageComposerContext`, `useMessageListContext` and `useMessageBounceContext`. TypeScript flags
every call site (`TS2554`). In plain JS the extra argument is simply ignored — harmless, but dead;
drop it.

### Required contexts throw outside their provider

In v14 these hooks logged a `console.warn` and returned `{}`, so a component rendered outside its
provider rendered blank and usually failed later with `Cannot read properties of undefined`. In v15
the hook throws where the mistake is, naming itself and the provider it needs:

| hook | must be rendered within |
| ---------------------------------- | --------------------------------------- |
| `useChatContext` | `<Chat>` |
| `useMessageContext` | `MessageProvider` (a rendered message) |
| `useMessageComposerContext` | `MessageComposerContextProvider` |
| `useMessageListContext` | `MessageListContextProvider` |
| `useVirtualizedMessageListContext` | `VirtualizedMessageListContextProvider` |
| `useMessageBounceContext` | `MessageBounceProvider` |
| `usePollContext` | `PollProvider` |
| `useDialogManager` | `DialogManagerProvider` |
| `useSearchContext` | `SearchContextProvider` |
| `useSearchSourceResultsContext` | `SearchSourceResultsProvider` |
| `useContextMenuContext` | `ContextMenu` |
| `useChannelListItemContext` | `ChannelListItemUI` |
| `useGalleryContext` | `Gallery` |
| `useChatViewContext` | `ChatView` |

**What to look for in the app:** any custom component that calls one of these hooks but is mounted
outside the provider — most often a custom message UI or attachment component rendered outside a
message, or a component mounted outside `<Chat>` for a loading / empty state. In v14 these rendered
blank with a console warning; in v15 they throw. Move them inside the provider, or wrap them in it.

These hooks are unchanged and still work outside their provider — no action needed:
`useTranslationContext` (renders the English copy), `useComponentContext` (empty override map),
`useChannelInstanceContext`, `useModalContext`, `useAriaLiveAnnouncer` and
`useMessageTranslationViewContext`.

### Related type changes

- `useChannelInstanceContext()` returns `Partial<ChannelInstanceContextValue>` — `channel` may be
`undefined`. Use `useChannel()` when a channel is required; it throws.
- `ChatViewContext` has no default value, so `useContext(ChatViewContext)` may be `undefined`.
- `MessageComposerContext` is typed `MessageComposerContextValue | undefined`.
- The gallery header renders the gallery item's own timestamp and no longer honors the
`ComponentContext.MessageTimestamp` override.
2 changes: 1 addition & 1 deletion examples/tutorial/src/4-custom-ui-components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ const App = () => {
<WithComponents
overrides={{
ChannelListItemUI: CustomChannelListItem,
Message: CustomMessage,
MessageUI: CustomMessage,
}}
>
<Chat client={client} theme='custom-theme'>
Expand Down
9 changes: 1 addition & 8 deletions src/components/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ type MessageWithContextProps = Omit<MessageProps, MessagePropsToOmit> &

const MessageWithContext = (props: MessageWithContextProps) => {
const {
Message: propMessage,
message,
messageActions = Object.keys(MESSAGE_ACTIONS),
onUserClick: propOnUserClick,
Expand All @@ -68,11 +67,7 @@ const MessageWithContext = (props: MessageWithContextProps) => {
const channel = useChannel();
const { isMessageAIGenerated } = useChatContext();
const channelConfig = useChannelConfig({ cid: channel.cid });
const {
Message: contextMessage = DefaultMessageUI,
// TODO: remove this passthrough once we drop Message from the ComponentContext
MessageUI: contextMessageUI = contextMessage,
} = useComponentContext();
const { MessageUI: MessageUIComponent = DefaultMessageUI } = useComponentContext();
const { getTranslationView, setTranslationView: setTranslationViewInContext } =
useMessageTranslationViewContext();

Expand All @@ -83,7 +78,6 @@ const MessageWithContext = (props: MessageWithContextProps) => {
);

const actionsEnabled = message.type === 'regular' && message.status === 'received';
const MessageUIComponent = propMessage ?? contextMessageUI;

const { onUserClick, onUserHover } = useUserHandler(message, {
onUserClickHandler: propOnUserClick,
Expand Down Expand Up @@ -241,7 +235,6 @@ export const Message = (props: MessageProps) => {
lastOwnMessage={props.lastOwnMessage}
lastReceivedId={props.lastReceivedId}
message={message}
Message={props.Message}
messageActions={props.messageActions}
messageListRect={props.messageListRect}
onMentionsClickMessage={onMentionsClick}
Expand Down
24 changes: 13 additions & 11 deletions src/components/Message/__tests__/Message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ async function renderComponent({
<Channel channel={channel}>
<WithComponents
overrides={{
Message: () => <CustomMessageUIComponent contextCallback={contextCallback} />,
MessageUI: () => (
<CustomMessageUIComponent contextCallback={contextCallback} />
),
reactionOptions: defaultReactionOptions,
...components,
}}
Expand Down Expand Up @@ -877,7 +879,7 @@ describe('<Message /> component', () => {
const UIMock = vi.fn(() => <div>UI mock</div>);

const { rerender } = await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message,
});

Expand All @@ -886,7 +888,7 @@ describe('<Message /> component', () => {
UIMock.mockClear();

await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message: updatedMessage,
renderer: rerender,
});
Expand All @@ -899,15 +901,15 @@ describe('<Message /> component', () => {
const UIMock = vi.fn(() => <div>UI mock</div>);

const { rerender } = await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message,
});

expect(UIMock).toHaveBeenCalledTimes(1);
UIMock.mockClear();

await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message,
props: { readBy: [bob] },
renderer: rerender,
Expand All @@ -921,7 +923,7 @@ describe('<Message /> component', () => {
const UIMock = vi.fn(() => <div>UI mock</div>);

const { rerender } = await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message,
props: { groupStyles: ['bottom'] },
});
Expand All @@ -930,7 +932,7 @@ describe('<Message /> component', () => {
UIMock.mockClear();

await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message,
props: { groupStyles: ['bottom', 'left'] as any },
renderer: rerender,
Expand All @@ -944,7 +946,7 @@ describe('<Message /> component', () => {
const UIMock = vi.fn(() => <div>UI mock</div>);

const { rerender } = await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message,
props: { lastReceivedId: 'last-received-id-1' },
});
Expand All @@ -953,7 +955,7 @@ describe('<Message /> component', () => {
UIMock.mockClear();

await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message,
props: { lastReceivedId: 'last-received-id-2' },
renderer: rerender,
Expand All @@ -967,7 +969,7 @@ describe('<Message /> component', () => {
const UIMock = vi.fn(() => <div>UI mock</div>);

const { rerender } = await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message,
props: {
messageListRect: fromPartial<DOMRect>({
Expand All @@ -983,7 +985,7 @@ describe('<Message /> component', () => {
UIMock.mockClear();

await renderComponent({
components: { Message: UIMock },
components: { MessageUI: UIMock },
message,
props: {
messageListRect: fromPartial<DOMRect>({
Expand Down
2 changes: 1 addition & 1 deletion src/components/Message/__tests__/MessageText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ async function renderMessageText({ customProps = {} } = {}) {
value={mockComponentContext({
Attachment,

Message: () => <MessageUI />,
MessageUI: () => <MessageUI />,
reactionOptions: defaultReactionOptions,
})}
>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Message/__tests__/MessageUI.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe('<MessageSimple />', () => {
<WithComponents
overrides={{
Attachment: AttachmentMock,
Message: () => <MessageUI {...props} />,
MessageUI: () => <MessageUI {...props} />,
reactionOptions: defaultReactionOptions,
...components,
}}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Message/__tests__/QuotedMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ async function renderQuotedMessage({
>
<ComponentProvider
value={mockComponentContext({
Message: () => <MessageUI />,
MessageUI: () => <MessageUI />,
...componentContext,
})}
>
Expand Down
5 changes: 0 additions & 5 deletions src/components/Message/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type { MessageActionsArray } from './utils';
import type { GroupStyle } from '../MessageList/utils';
import type { MessageComposerProps } from '../MessageComposer';
import type { ReactionsComparator } from '../Reactions/types';
import type { ComponentContextValue } from '../../context/ComponentContext';
import type { MessageContextValue } from '../../context/MessageContext';
import type { RenderTextFunction } from './renderText';

Expand Down Expand Up @@ -41,10 +40,6 @@ export type MessageProps = {
// todo: could be moved to the Channel instance reactive state as lastReceivedMessage keeping the the receipt status as well (useful for channel preview)
/** Latest message id on current channel */
lastReceivedId?: string | null;
/** UI component to display a Message in MessageList, overrides value in [ComponentContext](https://getstream.io/chat/docs/sdk/react/contexts/component_context/#message)
* @deprecated use `ComponentContext` (`WithComponents`) component override instead (`MessageUI` slot)
*/
Message?: ComponentContextValue['MessageUI'];
/** Array of allowed message actions (ex: ['edit', 'delete', 'flag', 'mute', 'pin', 'quote', 'react', 'reply']). To disable all actions, provide an empty array. */
messageActions?: MessageActionsArray;
/** DOMRect object for parent MessageList component */
Expand Down
8 changes: 4 additions & 4 deletions src/components/Message/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,11 @@ export const areMessagePropsEqual = (
showDetailedReactions?: boolean;
},
) => {
const { message: prevMessage, Message: prevMessageUI } = prevProps;
const { message: nextMessage, Message: nextMessageUI } = nextProps;

if (prevMessageUI !== nextMessageUI) return false;
const { message: prevMessage } = prevProps;
const { message: nextMessage } = nextProps;

// The message UI component itself is not compared here: it is resolved from
// `ComponentContext`, and context updates re-render consumers regardless of `React.memo`.
if (nextProps.showDetailedReactions !== prevProps.showDetailedReactions) {
return false;
}
Expand Down
2 changes: 0 additions & 2 deletions src/components/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
closeReactionSelectorOnClick: props.closeReactionSelectorOnClick,
disableQuotedMessages: props.disableQuotedMessages,
formatDate: props.formatDate,
Message: props.Message,
messageActions,
messageListRect: wrapperRect,
onMentionsClick: props.onMentionsClick,
Expand Down Expand Up @@ -439,7 +438,6 @@ type PropsDrilledToMessage =
| 'closeReactionSelectorOnClick'
| 'disableQuotedMessages'
| 'formatDate'
| 'Message'
| 'messageActions'
| 'onMentionsClick'
| 'onMentionsHover'
Expand Down
Loading
Loading