Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
93adb3c
feat(MessageComposer): make composer previews a configurable set and …
MartinCupela Oct 28, 2026
699b505
chore(examples): configure the composer's preview sections in the vit…
MartinCupela Oct 28, 2026
1a2b13c
feat(Poll): make the poll creation dialog footer a component slot
MartinCupela Oct 28, 2026
16840bc
chore(examples): attach polls to the message while the Poll preview i…
MartinCupela Oct 28, 2026
b7022cf
feat(MessageComposer): show the poll's settings and a capped option l…
MartinCupela Oct 28, 2026
533e610
feat(Message): make the message bubble's content a configurable set
MartinCupela Oct 28, 2026
b924466
chore(examples): order the message bubble's content in a new Message …
MartinCupela Oct 28, 2026
c46af0f
chore(examples): send the attached poll with the message's text and a…
MartinCupela Oct 30, 2026
31a6a18
feat(MessageActions): let each message action carry its own visibilit…
MartinCupela Oct 30, 2026
6c2435a
fix(MessageComposer): drop the edit preview's nested previews wrapper
MartinCupela Oct 30, 2026
30b80ed
chore(deps): upgrate stream-chat to v10.0.0-rc.28
MartinCupela Nov 2, 2026
7ce7637
fix(Message): address review findings on bubble context, poll edit ru…
MartinCupela Nov 2, 2026
7962e1b
chore(deps): upgrate stream-chat to v10.0.0-rc.29
MartinCupela Nov 2, 2026
82a78fa
fix(MessageComposer): detach the poll on remove without deleting it
MartinCupela Nov 2, 2026
6f254c7
chore(deps): bump @stream-io/state-store to 1.2.0 to match stream-cha…
MartinCupela Nov 2, 2026
6ba38ab
refactor(MessageComposer)!: move the poll preview to the vite example
MartinCupela Nov 2, 2026
bef2e43
docs(ai-docs): catch createPoll() failures in the attach-poll example
MartinCupela Nov 2, 2026
52d1d46
Merge remote-tracking branch 'origin/master' into feat/composable-com…
MartinCupela Nov 2, 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
219 changes: 219 additions & 0 deletions ai-docs/ai-migration-v14-v15.md
Original file line number Diff line number Diff line change
Expand Up @@ -1804,6 +1804,21 @@ export const List = () => (

- Action names are typed by the exported `DefaultMessageActionType` union; a custom action may use any string.
- Permission and message-state filtering stays in `useBaseMessageActionSetFilter`: an action the user cannot perform, or that a thread reply does not support, is dropped whether or not you list it. Pass `disableBaseMessageActionSetFilter` to take that over.
- An action's own rule about the message lives on its item as `isVisible({ isMyMessage, isThreadReply, message })`. In `defaultMessageActionSet`, `edit` hides for a poll message and a giphy, `copyMessageText` for a message without text and `download` for one without a downloadable attachment. Replace the item to change its rule. `isVisible` can only hide an action: the capabilities, the features enabled on the server and the message's status are still checked by the base filter. Item rules apply even with `disableBaseMessageActionSetFilter`, and to failed messages: v14 offered Edit on any message that failed to send for a network reason, while a network-failed poll message or giphy now gets no Edit. For example, to let users edit the text and attachments sent with a poll:

```tsx
import { defaultMessageActionSet, type MessageActionSetItem } from 'stream-chat-react';

const messageActionSet = defaultMessageActionSet.map(
(item): MessageActionSetItem =>
'type' in item && item.type === 'edit'
? { ...item, isVisible: ({ message }) => message.command !== 'giphy' }
: item,
);
```

- A hand-written `edit`, `copyMessageText` or `download` item without `isVisible` now shows wherever the base filter allows it, e.g. `edit` on a poll message the user may update. Copy the default item's `isVisible` to keep the default rule.
- `useUserRole(message).canEdit` is now the permission alone (`update-any-message`, or `update-own-message` on the user's own message), the same check the base filter makes. It no longer excludes poll messages and giphies; a custom UI that gated an edit button on it and wants that rule adds `!message.poll_id && message.command !== 'giphy'` itself.
- There is no `actionsEnabled` replacement. The base filter already drops actions on deleted, bounced and failed messages.
- **A custom `MessageUI` that gated its own buttons** on `getMessageActions()` / `MESSAGE_ACTIONS` (for example showing a react or reply button only when allowed) reads the permission from `useUserRole(message)`, which returns `canReact`, `canReply`, `canPin`, `canEdit`, `canDelete`, `canFlag`, `canMute`, `canQuote`, `canMarkUnread`, `canBlockUser`, `canSendMessage`, `isMyMessage` and the role flags:

Expand Down Expand Up @@ -2061,6 +2076,42 @@ The gallery renders outside any message, so its header reads the current item. W
- To change only the format, customize `timestamp.GalleryTimestamp`.
- `MessageTimestamp` renders from its `message` prop outside a `MessageProvider`; without the prop it still requires one.

### New: the message bubble's content is a configurable set; the quoted message renders above a poll

`MessageUI` renders the bubble's content through `MessageBubbleContent`, which renders a `messageBubbleContentSet` the way `MessageComposerPreviews` renders the composer's previews. Each item names itself with a `type`, renders a `Component` without props (read the message with `useMessageContext()`), and shows while `isVisible({ isAIGenerated, message })` holds.

`defaultMessageBubbleContentSet` renders `quotedMessage`, `poll`, `attachments` and `text`. v14 rendered the poll above the quoted message; a message that both quotes and carries a poll now shows the quote first. Anything else renders as before.

A message can carry a poll next to its text and attachments, and the root element now has `str-chat__message--has-poll` while it does. Such a message no longer gets `str-chat__message--has-single-attachment`, whose layout is meant for an attachment alone in the bubble.

Reorder, filter or extend the default set, then install it with the `MessageBubbleContent` slot:

```tsx
import {
defaultMessageBubbleContentSet,
MessageBubbleContent,
WithComponents,
} from 'stream-chat-react';

// Text above the poll and attachments, quote still first.
const order = ['quotedMessage', 'text', 'poll', 'attachments'];
const contentSet = order.flatMap((type) =>
defaultMessageBubbleContentSet.filter((item) => item.type === type),
);

const CustomBubbleContent = () => (
<MessageBubbleContent messageBubbleContentSet={contentSet} />
);

export const App = ({ children }: { children: React.ReactNode }) => (
<WithComponents overrides={{ MessageBubbleContent: CustomBubbleContent }}>
{children}
</WithComponents>
);
```

The props passed to `MessageUI` still override `MessageContext` for the bubble's content.

---

## 13. Message composer
Expand Down Expand Up @@ -2267,6 +2318,174 @@ export const ComposerPreviews = ({ children }: { children: ReactNode }) => (
);
```

### New: composer previews are a configurable set

Mostly not a break (see the slot overrides below). The previews above the composer's textarea (`.str-chat__message-composer-previews`) come from `MessageComposerPreviews`, which renders a `messageComposerPreviewSet` the way `MessageActions` renders a `messageActionSet`. Each item names itself with a `type`, renders a `Component` without props, and shows while `isVisible(state)` holds. `state` (`MessageComposerPreviewVisibilityFilterParams`) carries the composer's `attachments`, `customData`, `editedMessage`, `linkPreviews` (loading or loaded), `location`, `pollId` and `quotedMessage`. The preview area renders when any item is visible.

`defaultMessageComposerPreviewSet` keeps v14's previews in v14's order (`editedMessage`, `quotedMessage`, `voiceRecording`, `attachments`, `linkPreviews`, `location`). It has no poll preview: by default the poll creation dialog sends the poll on its own, so the composer never holds one for long. An app that attaches polls to the message adds its own item (see below).

Filter, reorder or extend the default set, then install it with the `MessageComposerPreviews` slot:

```tsx
import {
defaultMessageComposerPreviewSet,
MessageComposerPreviews,
type MessageComposerPreviewSetItem,
useMessageComposerController,
WithComponents,
} from 'stream-chat-react';

const ReplyingInChannelHint = () => {
const messageComposer = useMessageComposerController();
return <div>Replying in {messageComposer.channel.data?.name}</div>;
};

const previewSet: MessageComposerPreviewSetItem[] = [
...defaultMessageComposerPreviewSet.filter(({ type }) => type !== 'linkPreviews'),
{
Component: ReplyingInChannelHint,
isVisible: ({ quotedMessage }) => !!quotedMessage,
type: 'replyingInChannelHint',
},
];

const CustomComposerPreviews = () => (
<MessageComposerPreviews messageComposerPreviewSet={previewSet} />
);

export const App = ({ children }: { children: React.ReactNode }) => (
<WithComponents overrides={{ MessageComposerPreviews: CustomComposerPreviews }}>
{children}
</WithComponents>
);
```

- `useMessageComposerPreviewSetFilter(set)` returns the visible items, and `useMessageComposerPreviewState()` the state they are checked against, for a custom container.
- `isVisible` sees only the state above, so a preview that depends on something else must read it inside its `Component` and return `null`. To drive visibility from app state, put it in `messageComposer.customDataManager.setCustomData(...)` and read `customData`.
- `EditedMessagePreview` and `GeolocationPreview` are now exported from the root.
- **Custom `AttachmentPreviewList`, `VoiceRecordingPreviewSlot` and `LinkPreviewList` slots mount only while their default item is visible.** v14 mounted them whenever the preview area showed. The `attachments` item shows while the composer holds an attachment other than a voice recording, `voiceRecording` while it holds a voice recording, and `linkPreviews` while a link preview is loading or loaded. A custom component that also rendered something else, e.g. the `location` or `poll` from `useAttachmentsForPreview()` or an empty or upload state, stops mounting when its item is hidden. Replace that item with one whose `isVisible` covers what the component renders, and drop the default item that would render the same thing again:

```tsx
// The custom AttachmentPreviewList also renders the location.
const previewSet = defaultMessageComposerPreviewSet
.filter(({ type }) => type !== 'location')
.map(
(item): MessageComposerPreviewSetItem =>
item.type === 'attachments'
? {
...item,
isVisible: ({ attachments, location }) =>
attachments.length > 0 || !!location,
}
: item,
);
```

The default poll creation dialog sends the poll as soon as it is created. To have the dialog attach the poll to the message being composed instead:

1. Remove `stream-chat`'s poll-only step from the composition chain. With it, `stream-chat` sends a composer's poll as a message of its own and keeps the text and attachments in the composer. Without it, the poll is sent with them and the composer clears:

```ts
import { POLL_ONLY_COMPOSITION_MIDDLEWARE_ID } from 'stream-chat';

client.config.setSetupFunction('messageComposer', ({ composer }) => {
composer.compositionMiddlewareExecutor.remove(POLL_ONLY_COMPOSITION_MIDDLEWARE_ID);
});
```

2. Replace the dialog's footer through the new `PollCreationDialogControls` slot (`PollCreationDialogControls` and `PollCreationDialogControlsProps` are exported) and call `createPoll()` without `send()`:

```tsx
import {
type PollCreationDialogControlsProps,
Prompt,
useMessageComposerController,
WithComponents,
} from 'stream-chat-react';

const AttachPollControls = ({ close }: PollCreationDialogControlsProps) => {
const messageComposer = useMessageComposerController();

return (
<Prompt.Footer>
<Prompt.FooterControls>
<Prompt.FooterControlsButtonSecondary onClick={close} type='button'>
Cancel
</Prompt.FooterControlsButtonSecondary>
<Prompt.FooterControlsButtonPrimary
onClick={async () => {
close();
try {
// The poll waits in `messageComposer.state.pollId` and goes out with the message.
await messageComposer.createPoll();
} catch {
// `createPoll()` already reported the failure as a notification.
}
}}
type='submit'
>
Attach poll
</Prompt.FooterControlsButtonPrimary>
</Prompt.FooterControls>
</Prompt.Footer>
);
};

export const App = ({ children }: { children: React.ReactNode }) => (
<WithComponents overrides={{ PollCreationDialogControls: AttachPollControls }}>
{children}
</WithComponents>
);
```

3. Show the attached poll above the textarea with your own preview item. `pollId` in the visibility params holds the poll created with `createPoll()` and not sent yet. Its remove button should only detach the poll (`pollId: null`), not delete it: the composer cannot tell whether it created the poll or whether it was sent, e.g. a `poll_id` restored from a draft. The Vite example's `examples/vite/src/ComposerPreviews/PollPreview.tsx` is a complete one (question, options, settings, a poll fetched when it is not cached):

```tsx
import type { MessageComposerState } from 'stream-chat';
import {
defaultMessageComposerPreviewSet,
MessageComposerPreviews,
type MessageComposerPreviewSetItem,
RemoveAttachmentPreviewButton,
useChatContext,
useMessageComposerController,
useStateStore,
} from 'stream-chat-react';

const pollIdSelector = ({ pollId }: MessageComposerState) => ({ pollId });

const PollPreview = () => {
const { client } = useChatContext();
const messageComposer = useMessageComposerController();
const { pollId } = useStateStore(messageComposer.state, pollIdSelector);
const poll = pollId ? client.polls.fromState(pollId) : undefined;

return (
<div className='app-poll-preview'>
{poll?.data.name ?? 'Poll'}
<RemoveAttachmentPreviewButton
aria-label='Remove poll'
onClick={() => messageComposer.state.partialNext({ pollId: null })}
/>
</div>
);
};

const previewSet: MessageComposerPreviewSetItem[] = [
...defaultMessageComposerPreviewSet,
{
Component: PollPreview,
// An edited message keeps its poll; it cannot be replaced or removed.
isVisible: ({ editedMessage, pollId }) => !editedMessage && !!pollId,
type: 'poll',
},
];

export const ComposerPreviews = () => (
<MessageComposerPreviews messageComposerPreviewSet={previewSet} />
);
```

---

## 14. Request handlers
Expand Down
2 changes: 1 addition & 1 deletion examples/ai-chatbot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"next": "^16.2.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "10.0.0-rc.28",
"stream-chat": "10.0.0-rc.29",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion examples/cookbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-leaflet": "^5.0.0",
"stream-chat": "10.0.0-rc.28",
"stream-chat": "10.0.0-rc.29",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion examples/tutorial/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"emoji-mart": "^5.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "10.0.0-rc.28",
"stream-chat": "10.0.0-rc.29",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion examples/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "10.0.0-rc.28",
"stream-chat": "10.0.0-rc.29",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
66 changes: 61 additions & 5 deletions examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ import {
createCommandInjectionMiddleware,
createCommandStringExtractionMiddleware,
createDraftCommandInjectionMiddleware,
createPollOnlyCompositionMiddleware,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge order: createPollOnlyCompositionMiddleware and POLL_ONLY_COMPOSITION_MIDDLEWARE_ID exist only after GetStream/stream-chat-js#1970. yarn examples:build runs in CI, so this needs a stream-chat release and a dependency bump before it can go green. Nothing in this diff bumps stream-chat yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. GetStream/stream-chat-js#1970 is merged but came after rc.28, and POLL_ONLY_COMPOSITION_MIDDLEWARE_ID isn't in rc.28. This PR needs rc.29 and a bump before merging.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: done. 7962e1b bumps stream-chat to 10.0.0-rc.29 (root and examples/vite), which includes GetStream/stream-chat-js#1970 and exports POLL_ONLY_COMPOSITION_MIDDLEWARE_ID and createPollOnlyCompositionMiddleware. 6f254c7 bumps @stream-io/state-store to 1.2.0 to match it, so there is a single copy and yarn types is clean again.

createPriorityOwnershipResolver,
isVoiceRecordingAttachment,
MessageSearchSource,
POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
SearchController,
UserSearchSource,
} from 'stream-chat';
Expand Down Expand Up @@ -96,6 +99,12 @@ import { ConfigurableMessageActions } from './CustomMessageActions';
import { InlineEditableMessage } from './InlineEditMessage';
import { SidebarToggle } from './Sidebar/SidebarToggle.tsx';
import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx';
import {
ConfigurableComposerPreviews,
ConfigurableStartRecordingAudioButton,
} from './ComposerPreviews/ConfigurableComposerPreviews.tsx';
import { ConfigurablePollCreationDialogControls } from './ComposerPreviews/ConfigurablePollCreationDialogControls.tsx';
import { ConfigurableMessageBubbleContent } from './MessageBubble/ConfigurableMessageBubbleContent.tsx';
import { StreamDebugHandles } from './Debug';
import {
installUploadHarness,
Expand Down Expand Up @@ -291,8 +300,17 @@ const formatDocumentTitle = ({
const App = () => {
const { tokenProvider, userId, userImage, userName } = useUser();
const chatView = useAppSettingsSelector((state) => state.chatView);
const { failUploads, sendMessagesWithPendingUploads, slowUploads, uploadDestination } =
useAppSettingsSelector((state) => state.composer);
const {
disabledPreviewSections,
failUploads,
sendMessagesWithPendingUploads,
slowUploads,
uploadDestination,
} = useAppSettingsSelector((state) => state.composer);
const filesDisabled = disabledPreviewSections.includes('attachments');
const linkPreviewsDisabled = disabledPreviewSections.includes('linkPreviews');
const locationDisabled = disabledPreviewSections.includes('location');
const pollDisabled = disabledPreviewSections.includes('poll');
// Project to a stable-shape object rather than returning `state.layout` directly. `layout`
// starts as `{}`, and useStateStore only diffs the keys present in its *cached* selection — so
// a selection that starts empty never notices `channelCid` appearing later, and the modal would
Expand Down Expand Up @@ -573,12 +591,46 @@ const App = () => {
unique: true,
});

// A preview section left out in Settings -> Composer also closes the way into it, so the
// composer never holds something it cannot show. Voice recordings are attachments too, so
// removing Files filters uploads rather than disabling the attachment manager.
composer.updateConfig({
linkPreviews: { enabled: true },
location: { enabled: true },
attachments: {
fileUploadFilter: filesDisabled ? isVoiceRecordingAttachment : () => true,
},
linkPreviews: { enabled: !linkPreviewsDisabled },
location: { enabled: !locationDisabled },
polls: { enabled: !pollDisabled },
});

// While the Poll preview shows, the poll dialog attaches the poll to the message being
// composed (ConfigurablePollCreationDialogControls). Without the poll-only step the poll is
// sent with the text and attachments rather than as a message of its own, and the composer
// clears after the send. Restored when the preview is turned off, since this setup re-runs.
if (pollDisabled) {
composer.compositionMiddlewareExecutor.insert({
middleware: [createPollOnlyCompositionMiddleware(composer)],
position: {
after: 'stream-io/message-composer-middleware/user-data-injection',
},
unique: true,
});
} else {
composer.compositionMiddlewareExecutor.remove(
POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
);
}
});
}, [chatClient, failUploads, slowUploads, uploadDestination]);
}, [
chatClient,
failUploads,
filesDisabled,
linkPreviewsDisabled,
locationDisabled,
pollDisabled,
slowUploads,
uploadDestination,
]);

useEffect(() => {
if (!chatClient) return;
Expand Down Expand Up @@ -671,6 +723,10 @@ const App = () => {
HeaderEndContent: SidebarToggle,
MessageActions: ConfigurableMessageActions,
AttachmentSelector: CommandModeAttachmentSelector,
MessageBubbleContent: ConfigurableMessageBubbleContent,
MessageComposerPreviews: ConfigurableComposerPreviews,
PollCreationDialogControls: ConfigurablePollCreationDialogControls,
StartRecordingAudioButton: ConfigurableStartRecordingAudioButton,
MessageUI: InlineEditableMessage,
...messageUiOverrides,
}}
Expand Down
Loading
Loading