Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
a951519
fix!: drop unset MessageContext fields and unify message grouping props
MartinCupela Nov 3, 2026
150dbeb
docs(examples): detect threads with useThreadContext and use groupByUser
MartinCupela Nov 3, 2026
3e7d5bb
Merge remote-tracking branch 'origin/master' into fix/message-context…
MartinCupela Nov 3, 2026
1d2ccd3
refactor!: remove deprecated message APIs before v15 GA
MartinCupela Nov 3, 2026
c9493e8
Merge remote-tracking branch 'origin/fix/message-context-thread-group…
MartinCupela Nov 3, 2026
f1c55a9
docs(ai-docs): re-point breaking-changes line refs after merging the …
MartinCupela Nov 3, 2026
aeae34d
docs(ai-docs): cite PR #3387 in the REACT-1467 breaking-changes entries
MartinCupela Nov 3, 2026
763487b
docs(examples): keep thread-parent receipts and actions in the examples
MartinCupela Nov 4, 2026
8ac2ce4
docs: keep thread-parent UI in the cookbook message UIs and use the c…
MartinCupela Nov 4, 2026
21fe303
docs(ai-docs): handle keyboard activation in the onMentionsClick example
MartinCupela Nov 4, 2026
d15fa47
refactor(Message)!: remove disableQuotedMessages and pass showAvatar …
MartinCupela Nov 4, 2026
3b25df1
docs(ai-docs): fix stale message-list refs and the keyboard branch of…
MartinCupela Nov 4, 2026
5af87c5
Merge remote-tracking branch 'origin/master' into chore/remove-deprec…
MartinCupela Nov 4, 2026
8833ff8
docs: show no status or pin on thread messages in the examples and op…
MartinCupela Nov 4, 2026
bb0e147
Merge remote-tracking branch 'origin/master' into chore/remove-deprec…
MartinCupela Nov 4, 2026
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
703 changes: 460 additions & 243 deletions ai-docs/ai-migration-v14-v15.md

Large diffs are not rendered by default.

413 changes: 384 additions & 29 deletions ai-docs/v15-breaking-changes.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
MessageText,
useChatContext,
useMessageContext,
useMessagePermissions,
useThreadContext,
useUserRole,
} from 'stream-chat-react';
import './CustomMessageUi.css';

Expand Down Expand Up @@ -63,6 +63,9 @@ const CustomMessageUiMetadata = () => {
readBy = [],
} = useMessageContext();
const { client } = useChatContext();
// Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo`
// say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a
// message shown in a thread is never read, delivered or sent here.
const isInThread = !!useThreadContext();

const createdAt = convertTimestampToDate(message.created_at);
Expand Down Expand Up @@ -107,8 +110,10 @@ const CustomMessageUiMetadata = () => {
const CustomMessageUiActions = () => {
const { handleDelete, handleFlag, handleOpenThread, handlePin, message } =
useMessageContext();
// Pinning and replying work on the channel list, so the thread's messages, the parent
// included, offer neither, as in the SDK's default message actions.
const isInThread = !!useThreadContext();
const { canDelete, canFlag, canPin, canReply } = useUserRole(message);
const { canDelete, canFlag, canPin, canReply } = useMessagePermissions(message);

return (
<div className='cookbook-message-ui__actions'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
useChatContext,
useComponentContext,
useMessageContext,
useMessagePermissions,
useThreadContext,
useUserRole,
} from 'stream-chat-react';
import './CustomMessageUi.css';

Expand Down Expand Up @@ -65,6 +65,9 @@ const CustomMessageUiMetadata = () => {
readBy = [],
} = useMessageContext();
const { client } = useChatContext();
// Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo`
// say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a
// message shown in a thread is never read, delivered or sent here.
const isInThread = !!useThreadContext();

const createdAt = convertTimestampToDate(message.created_at);
Expand Down Expand Up @@ -115,8 +118,11 @@ const CustomMessageUiActions = () => {
handleReaction,
message,
} = useMessageContext();
// Pinning and replying work on the channel list, so the thread's messages, the parent
// included, offer neither, as in the SDK's default message actions.
const isInThread = !!useThreadContext();
const { canDelete, canFlag, canPin, canReact, canReply } = useUserRole(message);
const { canDelete, canFlag, canPin, canReact, canReply } =
useMessagePermissions(message);
const { reactionOptions } = useComponentContext();
const quickReactions =
reactionOptions && 'quick' in reactionOptions ? reactionOptions.quick : {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ const getMessageStatus = ({
const CustomMessageUiMetadata = () => {
const { deliveredTo = [], isMyMessage, message, readBy = [] } = useMessageContext();
const { client } = useChatContext();
// Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo`
// say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a
// message shown in a thread is never read, delivered or sent here.
const isInThread = !!useThreadContext();

const createdAt = convertTimestampToDate(message.created_at);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ const getMessageStatus = ({
const CustomMessageUiMetadata = () => {
const { deliveredTo = [], isMyMessage, message, readBy = [] } = useMessageContext();
const { client } = useChatContext();
// Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo`
// say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a
// message shown in a thread is never read, delivered or sent here.
const isInThread = !!useThreadContext();

const createdAt = convertTimestampToDate(message.created_at);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { convertTimestampToDate } from 'stream-chat';
import {
useChatContext,
useMessageContext,
useUserRole,
useMessagePermissions,
WithComponents,
} from 'stream-chat-react';
import './PinnedByIndicator.css';

const PinnedByIndicator = () => {
const { client } = useChatContext();
const { handlePin, message } = useMessageContext();
const { canPin } = useUserRole(message);
const { canPin } = useMessagePermissions(message);

if (!message.pinned) return null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ const readersSelector = ({ readersByMessageId }: MessageReceiptsSnapshot) => ({
const ReadReceiptAvatars = () => {
const channel = useChannel();
const { client } = useChatContext();
const { message } = useMessageContext();
const isInThread = !!useThreadContext();
const { initialMessage, message } = useMessageContext();
const isThreadReply = !!useThreadContext() && !initialMessage;
const { readersByMessageId } = useStateStore(
channel.messageReceiptsTracker.snapshotStore,
readersSelector,
);

// Read positions belong to the channel's main list, so a thread shows none.
if (isInThread) return null;
// Read positions belong to the channel's main list, so thread replies show none. The thread
// parent is a channel message and keeps its readers.
if (isThreadReply) return null;

const readers = (readersByMessageId[message.id] ?? []).filter(
(user) => user.id !== client.userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const Mention = ({ autoFocus, message }: MentionProps) => {
/>
</span>
<span className='cookbook-mention__text'>
{renderText(localMessage.text, undefined, {
{renderText(localMessage.text, {
allowedTagNames,
messageMentionEntities: getRenderTextMentionEntities(localMessage),
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const ReactionsTab = ({ close }: ReactionsTabProps) => {
const componentContext = useComponentContext();
const { client } = useChatContext();
// A real Channel, never queried or watched: `Message` reaches `channel.state` through
// `useUserRole` -> `useChannelCapabilities`, which subscribes to it as a StateStore. A plain
// object shaped like channel state crashes there, which is what this preview used to pass.
// `useMessagePermissions` -> `useChannelCapabilities`, which subscribes to it as a
// StateStore. A plain object shaped like channel state crashes there.
const previewChannel = useMemo(
() => client.channel('messaging', 'reactions-preview'),
[client],
Expand Down
25 changes: 21 additions & 4 deletions examples/vite/src/ChatLayout/Panels.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import clsx from 'clsx';
import type {
ChannelMemberResponse,
LocalMessage,
Channel as StreamChannel,
Thread as ThreadType,
} from 'stream-chat';
import {
type BaseSyntheticEvent,
type MouseEvent,
type PropsWithChildren,
useCallback,
Expand All @@ -19,6 +21,7 @@ import {
ChannelHeader,
ChannelListItem,
MessageComposerUI as DefaultMessageComposerUI,
getRenderTextMentionEntities,
MessageComposer,
MessageList,
type MessageListProps,
Expand Down Expand Up @@ -80,6 +83,20 @@ export const THREAD_LIST_TARGET_ID = 'app-thread-list';
export const CHANNELS_SELECTOR_BUTTON_TARGET_QUERY =
'[id^="str-chat__chat-view-"][id$="-tab-channels"]';

/**
* The user behind the clicked mention. Enter or Space targets the message text, not a mention, so
* it identifies a user only when the message's one mention is a user.
*/
const getMentionedUserId = (event: BaseSyntheticEvent, message: LocalMessage) => {
if (event.type === 'keydown') {
const [mention, ...otherMentions] = getRenderTextMentionEntities(message);
if (otherMentions.length || mention?.mentionType !== 'user') return undefined;
return mention.id;
}
return (event.target as HTMLElement).closest<HTMLElement>('[data-user-id]')?.dataset
.userId;
};

// Whether the app-settings "message list" toggle is set to the virtualized list. Read the same
// way everywhere so every channel and thread honors the setting (not just the primary channel).
const useVirtualizedMessageList = () =>
Expand Down Expand Up @@ -192,8 +209,8 @@ const ChannelThreadPanel = ({
<ReturnToSkipNavigation />
<ThreadHeader />
<ThreadMessageList
onMentionsClick={(_event, mentionedUsers) =>
onOpenMemberDetail(mentionedUsers[0]?.id)
onMentionsClick={(event, message) =>
onOpenMemberDetail(getMentionedUserId(event, message))
}
onUserClick={(_event, user) => onOpenMemberDetail(user.id)}
/>
Expand Down Expand Up @@ -459,8 +476,8 @@ const ResponsiveChannelPanels = ({ mainChannel }: { mainChannel?: StreamChannel
<VirtualizedMessageList groupByUser returnAllReadData />
) : (
<MessageList
onMentionsClick={(_event, mentionedUsers) =>
openMemberDetail(mentionedUsers[0]?.id)
onMentionsClick={(event, message) =>
openMemberDetail(getMentionedUserId(event, message))
}
onUserClick={(_event, user) => openMemberDetail(user.id)}
returnAllReadData
Expand Down
3 changes: 3 additions & 0 deletions examples/vite/src/CustomMessageUi/variants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ const CustomMessageUiMetadata = ({
message,
readBy = [],
} = useMessageContext();
// Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo`
// say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a
// message shown in a thread is never read, delivered or sent here.
const isInThread = !!useThreadContext();
const { client } = useChatContext();
const {
Expand Down
33 changes: 22 additions & 11 deletions src/components/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ import {
useFlagHandler,
useMarkUnreadHandler,
useMentionsHandler,
useMessagePermissions,
useMuteHandler,
useOpenThreadHandler,
usePinHandler,
useReactionHandler,
useReactionsFetcher,
useRetryHandler,
useUserHandler,
useUserRole,
} from './hooks';
import type { UserEventHandler } from './hooks';
import { areMessagePropsEqual } from './utils';
import { useStableCallback } from '../../utils';

import type { LocalMessage } from 'stream-chat';
import type { MessageContextValue } from '../../context';
Expand Down Expand Up @@ -50,15 +52,15 @@ type MessageContextPropsToPick =

type MessageWithContextProps = Omit<MessageProps, MessagePropsToOmit> &
Pick<MessageContextValue, MessageContextPropsToPick> & {
userRoles: ReturnType<typeof useUserRole>;
permissions: ReturnType<typeof useMessagePermissions>;
};

const MessageWithContext = (props: MessageWithContextProps) => {
const {
message,
onUserClick: propOnUserClick,
onUserHover: propOnUserHover,
userRoles,
permissions,
} = props;

const { isMessageAIGenerated } = useChatContext();
Expand All @@ -77,12 +79,12 @@ const MessageWithContext = (props: MessageWithContextProps) => {
onUserHoverHandler: propOnUserHover,
});

const { isMyMessage } = userRoles;
const { isMyMessage } = permissions;

const {
onUserClick: onUserClickPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars
onUserHover: onUserHoverPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars
userRoles: userRolesPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars
permissions: permissionsPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars
...rest
} = props;

Expand Down Expand Up @@ -115,18 +117,19 @@ const MemoizedMessage = React.memo(
export const Message = (props: MessageProps) => {
const {
closeReactionSelectorOnClick,
disableQuotedMessages,
message,
onMentionsClick: propOnMentionsClick,
onMentionsHover: propOnMentionsHover,
onUserClick: propOnUserClick,
onUserHover: propOnUserHover,
openThread: propOpenThread,
reactionDetailsSort,
sortReactions,
} = props;

const handleAction = useActionHandler(message);
const handleReaction = useReactionHandler(message);
const userRoles = useUserRole(message, disableQuotedMessages);
const permissions = useMessagePermissions(message);

const handleFetchReactions = useReactionsFetcher(message);

Expand All @@ -142,6 +145,14 @@ export const Message = (props: MessageProps) => {
onMentionsClick: propOnMentionsClick,
onMentionsHover: propOnMentionsHover,
});
// The memoized message ignores handler identity, so a swapped `onUserClick` / `onUserHover`
// reaches it through these stable wrappers, which always call the latest prop.
const onUserClick: UserEventHandler = useStableCallback((event, user) =>
propOnUserClick?.(event, user),
);
const onUserHover: UserEventHandler = useStableCallback((event, user) =>
propOnUserHover?.(event, user),
);

const { handlePin } = usePinHandler(message);
const handleOpenThread = useOpenThreadHandler(message, propOpenThread);
Expand All @@ -157,7 +168,6 @@ export const Message = (props: MessageProps) => {
autoscrollToBottom={props.autoscrollToBottom}
closeReactionSelectorOnClick={closeReactionSelectorOnClick}
deliveredTo={props.deliveredTo}
disableQuotedMessages={props.disableQuotedMessages}
formatDate={props.formatDate}
groupStyles={props.groupStyles}
handleAction={handleAction}
Expand All @@ -178,15 +188,16 @@ export const Message = (props: MessageProps) => {
messageListRect={props.messageListRect}
onMentionsClickMessage={onMentionsClick}
onMentionsHoverMessage={onMentionsHover}
onUserClick={props.onUserClick}
onUserHover={props.onUserHover}
onUserClick={onUserClick}
onUserHover={onUserHover}
permissions={permissions}
reactionDetailsSort={reactionDetailsSort}
readBy={props.readBy}
renderText={props.renderText}
returnAllReadData={props.returnAllReadData}
showAvatar={props.showAvatar}
sortReactions={sortReactions}
unsafeHTML={props.unsafeHTML}
userRoles={userRoles}
/>
);
};
4 changes: 2 additions & 2 deletions src/components/Message/MessageBlocked.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import React from 'react';
import clsx from 'clsx';

import { useUserRole } from './hooks/useUserRole';
import { useMessagePermissions } from './hooks/useMessagePermissions';
import { useTranslationContext } from '../../context/TranslationContext';
import { useMessageContext } from '../../context';

export const MessageBlocked = () => {
const { message } = useMessageContext();
const { t } = useTranslationContext();

const { isMyMessage } = useUserRole(message);
const { isMyMessage } = useMessagePermissions(message);

const messageClasses = clsx(
'str-chat__message str-chat__message--blocked',
Expand Down
14 changes: 8 additions & 6 deletions src/components/Message/MessageText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => {

const messageText = useMemo(
() =>
renderText(messageTextToRender, message.mentioned_users, {
renderText(messageTextToRender, {
messageMentionEntities: renderTextMentionEntities,
}),
[message.mentioned_users, messageTextToRender, renderText, renderTextMentionEntities],
[messageTextToRender, renderText, renderTextMentionEntities],
);

const wrapperClass = customWrapperClass || 'str-chat__message-text';
Expand All @@ -97,7 +97,7 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => {
}

event.preventDefault();
onMentionsClickMessage(event);
onMentionsClickMessage?.(event);
};

if (!messageTextToRender) return null;
Expand All @@ -115,12 +115,14 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => {
<div
aria-labelledby={isMentionsInteractionEnabled ? messageLabelledBy : undefined}
className={clsx(innerClass, {
[` str-chat__message-text-inner--is-emoji`]:
'str-chat__message-text-inner--has-attachment': hasAttachment,
'str-chat__message-text-inner--is-emoji':
messageTextHasEmojisOnly(message) && !message.quoted_message,
[`str-chat__message-text-inner--has-attachment`]: hasAttachment,
'str-chat__message-text-inner--mentions-clickable':
isMentionsInteractionEnabled,
})}
data-testid='message-text-inner-wrapper'
onClick={onMentionsClickMessage}
onClick={isMentionsInteractionEnabled ? onMentionsClickMessage : undefined}
onKeyDown={isMentionsInteractionEnabled ? handleMentionsKeyDown : undefined}
onMouseOver={onMentionsHoverMessage}
tabIndex={isMentionsInteractionEnabled ? 0 : undefined}
Expand Down
Loading
Loading