diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md
index 708ee996d..f51dfc98b 100644
--- a/ai-docs/ai-migration-v14-v15.md
+++ b/ai-docs/ai-migration-v14-v15.md
@@ -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:
@@ -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 = () => (
+
+);
+
+export const App = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+```
+
+The props passed to `MessageUI` still override `MessageContext` for the bubble's content.
+
---
## 13. Message composer
@@ -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
Replying in {messageComposer.channel.data?.name}
;
+};
+
+const previewSet: MessageComposerPreviewSetItem[] = [
+ ...defaultMessageComposerPreviewSet.filter(({ type }) => type !== 'linkPreviews'),
+ {
+ Component: ReplyingInChannelHint,
+ isVisible: ({ quotedMessage }) => !!quotedMessage,
+ type: 'replyingInChannelHint',
+ },
+];
+
+const CustomComposerPreviews = () => (
+
+);
+
+export const App = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+```
+
+- `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 (
+
+
+
+ Cancel
+
+ {
+ 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
+
+
+
+ );
+};
+
+export const App = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+```
+
+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 (
+
+ {poll?.data.name ?? 'Poll'}
+ messageComposer.state.partialNext({ pollId: null })}
+ />
+
+ );
+};
+
+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 = () => (
+
+);
+```
+
---
## 14. Request handlers
diff --git a/examples/ai-chatbot/package.json b/examples/ai-chatbot/package.json
index ab21c6912..5631ae2d0 100644
--- a/examples/ai-chatbot/package.json
+++ b/examples/ai-chatbot/package.json
@@ -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": {
diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json
index 99518e364..cf5c318be 100644
--- a/examples/cookbook/package.json
+++ b/examples/cookbook/package.json
@@ -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": {
diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.tsx b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.tsx
index 5107511d9..d4783c895 100644
--- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.tsx
+++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.tsx
@@ -7,7 +7,6 @@ import {
useMessageComposerController,
useStateStore,
} from 'stream-chat-react';
-import { deleteUnsentPoll } from './removePendingPoll';
import './AddPollDialog.css';
const pollFieldsSelector = ({ data }: PollComposerState) => ({
@@ -28,20 +27,13 @@ export const AddPollDialog = ({ close }: PollCreationDialogProps) => {
const addPoll = async () => {
close();
- // A poll already waiting in the composer, which the new one replaces.
- const previousPollId = messageComposer.pollId;
try {
- // Creates the poll on the server and sets `messageComposer.state.pollId`.
+ // Creates the poll on the server and sets `messageComposer.state.pollId`, so a new poll
+ // replaces one already waiting in the composer. The replaced poll stays on the server.
await messageComposer.createPoll();
} catch {
// `createPoll()` already reported the failure as a notification.
}
- // `createPoll()` may also return without creating anything, when the form doesn't compose.
- // Until a new poll is in the composer, the previous one stays there and on the server.
- const { pollId } = messageComposer;
- if (previousPollId && pollId && pollId !== previousPollId) {
- deleteUnsentPoll(messageComposer, previousPollId);
- }
};
return (
diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.tsx b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.tsx
index 8a03dc8bd..a90adc3c1 100644
--- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.tsx
+++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.tsx
@@ -88,7 +88,7 @@ export const ComposerPollPreview = () => {
return (
removePendingPoll(messageComposer)}
poll={poll}
/>
diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/removePendingPoll.ts b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/removePendingPoll.ts
index 4e1e66c3a..ef9cee584 100644
--- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/removePendingPoll.ts
+++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/removePendingPoll.ts
@@ -1,24 +1,13 @@
import type { MessageComposer } from 'stream-chat';
/**
- * Deletes a poll on the server that was created for a new message in this composer and never
- * sent, so nothing else points to it. Call it only on the user's own action. A poll of an edited
- * message belongs to that message and stays.
- */
-export const deleteUnsentPoll = (composer: MessageComposer, pollId: string) => {
- if (composer.editedMessage) return;
- composer.client.deletePoll({ poll_id: pollId }).catch((error) => {
- console.error('Deleting the unsent poll failed', error);
- });
-};
-
-/**
- * Takes the poll waiting in a new message out of the composer and deletes it on the server with
- * `deleteUnsentPoll()`. Call it when the user removes the poll.
+ * Takes the poll waiting in a new message out of the composer. Call it when the user removes the
+ * poll. The poll stays on the server: the composer can't tell a poll it created from one restored
+ * from a draft that may already be sent, and deleting a sent poll removes it and its votes. A poll
+ * of an edited message belongs to that message and stays attached.
*/
export const removePendingPoll = (composer: MessageComposer) => {
const { editedMessage, pollId } = composer;
if (!pollId || editedMessage) return;
composer.state.partialNext({ pollId: null });
- deleteUnsentPoll(composer, pollId);
};
diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json
index e68162832..dcc98341a 100644
--- a/examples/tutorial/package.json
+++ b/examples/tutorial/package.json
@@ -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": {
diff --git a/examples/vite/package.json b/examples/vite/package.json
index 3a10f9874..a24150b2d 100644
--- a/examples/vite/package.json
+++ b/examples/vite/package.json
@@ -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": {
diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx
index 9f5db4a60..c26f57962 100644
--- a/examples/vite/src/App.tsx
+++ b/examples/vite/src/App.tsx
@@ -21,8 +21,11 @@ import {
createCommandInjectionMiddleware,
createCommandStringExtractionMiddleware,
createDraftCommandInjectionMiddleware,
+ createPollOnlyCompositionMiddleware,
createPriorityOwnershipResolver,
+ isVoiceRecordingAttachment,
MessageSearchSource,
+ POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
SearchController,
UserSearchSource,
} from 'stream-chat';
@@ -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,
@@ -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
@@ -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;
@@ -671,6 +723,10 @@ const App = () => {
HeaderEndContent: SidebarToggle,
MessageActions: ConfigurableMessageActions,
AttachmentSelector: CommandModeAttachmentSelector,
+ MessageBubbleContent: ConfigurableMessageBubbleContent,
+ MessageComposerPreviews: ConfigurableComposerPreviews,
+ PollCreationDialogControls: ConfigurablePollCreationDialogControls,
+ StartRecordingAudioButton: ConfigurableStartRecordingAudioButton,
MessageUI: InlineEditableMessage,
...messageUiOverrides,
}}
diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss
index fc87ff868..d5c2e399c 100644
--- a/examples/vite/src/AppSettings/AppSettings.scss
+++ b/examples/vite/src/AppSettings/AppSettings.scss
@@ -1263,6 +1263,65 @@
padding: 10px 12px;
}
+ // A list of sections to drag into order (Settings -> Composer and Message).
+ .app__sortable-sections {
+ display: flex;
+ flex-direction: column;
+ gap: var(--str-chat__spacing-xs);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ .app__sortable-sections__item {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: var(--str-chat__spacing-sm);
+ align-items: center;
+ border: 1px solid var(--str-chat__border-core-default);
+ border-radius: 10px;
+ padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm);
+ background: var(--str-chat__background-core-elevation-1, transparent);
+ cursor: grab;
+
+ &--dragged {
+ opacity: 0.5;
+ }
+
+ &--drop-target {
+ border-color: var(--str-chat__accent-primary);
+ box-shadow: 0 0 0 1px var(--str-chat__accent-primary);
+ }
+
+ &--disabled .app__sortable-sections__text {
+ opacity: 0.55;
+ }
+ }
+
+ .app__sortable-sections__handle {
+ display: flex;
+ color: var(--str-chat__text-tertiary, var(--str-chat__text-secondary));
+ }
+
+ .app__sortable-sections__label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--str-chat__text-primary);
+ }
+
+ .app__sortable-sections__comment {
+ color: var(--str-chat__text-secondary);
+ font-size: 12px;
+ line-height: 1.4;
+ }
+
+ .app__sortable-sections__controls {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ cursor: default;
+ }
+
.app__settings-modal__option-button[aria-pressed='true'] {
// Deliberately no `background` or `border-color`. `str-chat__button` already draws the pressed state
// as an `::after` overlay tinted with `--str-chat__background-utility-selected`; setting the same
diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx
index dd9620cfa..68892e07c 100644
--- a/examples/vite/src/AppSettings/AppSettings.tsx
+++ b/examples/vite/src/AppSettings/AppSettings.tsx
@@ -22,6 +22,7 @@ import { ChannelDetailTab } from './tabs/ChannelDetail';
import { ComposerTab } from './tabs/Composer';
import { ConfigurationTab } from './tabs/Configuration';
import { GeneralTab } from './tabs/General';
+import { MessageTab } from './tabs/Message';
import { MessageActionsTab } from './tabs/MessageActions';
import { NotificationsTab } from './tabs/Notifications';
import { ReactionsTab } from './tabs/Reactions';
@@ -44,6 +45,7 @@ type TabId =
| 'composer'
| 'configuration'
| 'general'
+ | 'message'
| 'messageActions'
| 'notifications'
| 'reactions'
@@ -68,6 +70,7 @@ const settingsSectionConfig: SettingsSectionConfig[] = [
id: 'channelDetail',
title: 'Channel Detail',
},
+ { Content: MessageTab, Icon: IconMessageBubble, id: 'message', title: 'Message' },
{
Content: MessageActionsTab,
Icon: IconMessageBubble,
diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts
index 4d2a78194..861e6a6d9 100644
--- a/examples/vite/src/AppSettings/state.ts
+++ b/examples/vite/src/AppSettings/state.ts
@@ -1,5 +1,9 @@
import { StateStore } from '@stream-io/state-store';
-import { useStateStore } from 'stream-chat-react';
+import {
+ defaultMessageBubbleContentSet,
+ defaultMessageComposerPreviewSet,
+ useStateStore,
+} from 'stream-chat-react';
import { DEFAULT_LANGUAGE, streamI18n } from '../i18n';
import type { UploadDestination, UploadFailureMode } from '../SendWhilePendingUploads';
@@ -88,6 +92,14 @@ export type PanelLayoutSettingsState = {
threadPanel: ThreadPanelLayoutSettingsState;
};
+export type MessageBubbleSettingsState = {
+ /**
+ * `MessageBubbleContentSetItem['type']`s in render order. A default type missing from it is
+ * appended (see `resolveBubbleContentOrder`).
+ */
+ contentOrder: string[];
+};
+
export type MessageListSettingsState = {
type: 'standard' | 'virtualized';
};
@@ -102,6 +114,17 @@ export type LayoutSettingsState = {
export type ComposerSettingsState = {
/** The CDN upload endpoint used while `uploadDestination` is `custom-url`. */
customCdnUrl: string;
+ /**
+ * `MessageComposerPreviewSetItem['type']`s left out of the composer. Held as what is turned
+ * *off* so a newly shipped default preview appears without a settings migration. Removing one
+ * also removes the composer's way into it (e.g. `attachments` drops the selector's File option).
+ */
+ disabledPreviewSections: string[];
+ /**
+ * The composer's preview sections in render order. A default preview missing from it is
+ * appended (see `resolvePreviewSectionOrder`).
+ */
+ previewSectionOrder: string[];
/**
* Allow sending a message while its attachments are still uploading.
*/
@@ -136,6 +159,7 @@ export type AppSettingsState = {
language: LanguageSettingsState;
layout: LayoutSettingsState;
messageActions: MessageActionsSettingsState;
+ messageBubble: MessageBubbleSettingsState;
messageList: MessageListSettingsState;
notifications: NotificationsSettingsState;
panelLayout: PanelLayoutSettingsState;
@@ -185,7 +209,9 @@ const defaultAppSettingsState: AppSettingsState = {
},
composer: {
customCdnUrl: '',
+ disabledPreviewSections: [],
failUploads: 'off',
+ previewSectionOrder: defaultMessageComposerPreviewSet.map(({ type }) => type),
sendMessagesWithPendingUploads: false,
slowUploadMs: 20000,
slowUploads: false,
@@ -217,6 +243,9 @@ const defaultAppSettingsState: AppSettingsState = {
},
},
},
+ messageBubble: {
+ contentOrder: defaultMessageBubbleContentSet.map(({ type }) => type),
+ },
messageList: {
type: 'standard',
},
diff --git a/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx b/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx
index b295909eb..8fd49cb42 100644
--- a/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx
+++ b/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx
@@ -9,6 +9,7 @@ import {
SettingsTabBody,
SettingsTabLayoutHeader,
} from '../SettingsTabLayoutComponents.tsx';
+import { PreviewSectionsField } from './PreviewSectionsField';
const uploadDestinations: { label: string; value: UploadDestination }[] = [
{ label: 'Stream', value: 'stream' },
@@ -45,6 +46,21 @@ export const ComposerTab = ({ close }: ComposerTabProps) => {
/>
+
+
Preview sections
+
+ The previews above the textarea, rendered through{' '}
+ MessageComposerPreviews with a{' '}
+ messageComposerPreviewSet built from{' '}
+ defaultMessageComposerPreviewSet plus this app's Poll
+ preview (the SDK ships none). Drag a section, or use its arrows, to change the
+ order; switch it off to leave it out. A removed section also takes the
+ composer's way into it with it, so the composer never holds something it
+ cannot show. Applies to open composers at once.
+
+
+
+
Pending uploads
{
+ const { composer } = useAppSettingsState();
+ const { disabledPreviewSections } = composer;
+
+ return (
+
+ appSettingsStore.partialNext({
+ composer: {
+ ...composer,
+ disabledPreviewSections: enabled
+ ? disabledPreviewSections.filter((disabled) => disabled !== type)
+ : [...disabledPreviewSections, type],
+ },
+ })
+ }
+ onOrderChange={(previewSectionOrder) =>
+ appSettingsStore.partialNext({ composer: { ...composer, previewSectionOrder } })
+ }
+ sections={resolvePreviewSectionOrder(composer.previewSectionOrder).map((type) => {
+ const { label, removes } = PREVIEW_SECTION_INFO[type as PreviewSectionType];
+ return {
+ description: (
+ <>
+ {type}. Removing it also removes: {removes}
+ >
+ ),
+ label,
+ type,
+ };
+ })}
+ />
+ );
+};
diff --git a/examples/vite/src/AppSettings/tabs/Message/MessageTab.tsx b/examples/vite/src/AppSettings/tabs/Message/MessageTab.tsx
new file mode 100644
index 000000000..645e32d98
--- /dev/null
+++ b/examples/vite/src/AppSettings/tabs/Message/MessageTab.tsx
@@ -0,0 +1,59 @@
+import type { DefaultMessageBubbleContentType } from 'stream-chat-react';
+
+import {
+ BUBBLE_CONTENT_LABELS,
+ resolveBubbleContentOrder,
+} from '../../../MessageBubble/bubbleContent';
+import { appSettingsStore, useAppSettingsState } from '../../state';
+import {
+ SettingsTabBody,
+ SettingsTabLayoutHeader,
+} from '../SettingsTabLayoutComponents.tsx';
+import { SortableSectionList } from '../SortableSectionList';
+
+type MessageTabProps = {
+ close: () => void;
+};
+
+export const MessageTab = ({ close }: MessageTabProps) => {
+ const { messageBubble } = useAppSettingsState();
+
+ return (
+
+
+
+
+
+
Bubble content order
+
+ The order of what a message bubble holds, rendered through{' '}
+ MessageBubbleContent with a messageBubbleContentSet{' '}
+ built from defaultMessageBubbleContentSet. A message shows only
+ the parts it has. Drag a part, or use its arrows, to reorder it.
+
+
+ appSettingsStore.partialNext({
+ messageBubble: { ...messageBubble, contentOrder },
+ })
+ }
+ sections={resolveBubbleContentOrder(messageBubble.contentOrder).map(
+ (type) => ({
+ description: {type},
+ label:
+ BUBBLE_CONTENT_LABELS[type as DefaultMessageBubbleContentType] ?? type,
+ type,
+ }),
+ )}
+ />
+
+
+
+ );
+};
diff --git a/examples/vite/src/AppSettings/tabs/Message/index.ts b/examples/vite/src/AppSettings/tabs/Message/index.ts
new file mode 100644
index 000000000..05027c450
--- /dev/null
+++ b/examples/vite/src/AppSettings/tabs/Message/index.ts
@@ -0,0 +1 @@
+export * from './MessageTab';
diff --git a/examples/vite/src/AppSettings/tabs/SortableSectionList.tsx b/examples/vite/src/AppSettings/tabs/SortableSectionList.tsx
new file mode 100644
index 000000000..29f13c079
--- /dev/null
+++ b/examples/vite/src/AppSettings/tabs/SortableSectionList.tsx
@@ -0,0 +1,145 @@
+import clsx from 'clsx';
+import { type ReactNode, useState } from 'react';
+import {
+ Button,
+ IconArrowDown,
+ IconArrowUp,
+ IconReorder,
+ SwitchField,
+} from 'stream-chat-react';
+
+export type SortableSection = {
+ description?: ReactNode;
+ label: string;
+ type: string;
+};
+
+type SortableSectionListProps = {
+ /** Names the list for assistive tech. */
+ ariaLabel: string;
+ /** Prefixes the switches' element IDs, which must be unique on the page. */
+ idPrefix: string;
+ onOrderChange: (order: string[]) => void;
+ /** Sections in their current order. */
+ sections: SortableSection[];
+ /** Types switched off. Without `onEnabledChange` the rows have no switch. */
+ disabledTypes?: string[];
+ onEnabledChange?: (type: string, enabled: boolean) => void;
+};
+
+const moveItem = (order: string[], from: number, to: number) => {
+ const next = [...order];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ return next;
+};
+
+const moveButtonClassName =
+ 'str-chat__button--ghost str-chat__button--secondary str-chat__button--size-sm str-chat__button--circular';
+
+/**
+ * A list to drag into order, with up and down buttons doing the same from the keyboard, and an
+ * optional switch per row.
+ */
+export const SortableSectionList = ({
+ ariaLabel,
+ disabledTypes = [],
+ idPrefix,
+ onEnabledChange,
+ onOrderChange,
+ sections,
+}: SortableSectionListProps) => {
+ const order = sections.map(({ type }) => type);
+ const [draggedType, setDraggedType] = useState(null);
+ const [dropIndex, setDropIndex] = useState(null);
+
+ const endDrag = () => {
+ setDraggedType(null);
+ setDropIndex(null);
+ };
+
+ return (
+ {
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
+ setDropIndex(null);
+ }
+ }}
+ >
+ {sections.map(({ description, label, type }, index) => {
+ const enabled = !disabledTypes.includes(type);
+
+ return (
+ {
+ if (!draggedType) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ setDropIndex(index);
+ }}
+ onDragStart={(event) => {
+ event.dataTransfer.effectAllowed = 'move';
+ // Firefox starts a drag only once data is set.
+ event.dataTransfer.setData('text/plain', type);
+ setDraggedType(type);
+ }}
+ onDrop={(event) => {
+ event.preventDefault();
+ if (draggedType) {
+ onOrderChange(moveItem(order, order.indexOf(draggedType), index));
+ }
+ endDrag();
+ }}
+ >
+
+
+
+
+
{label}
+ {description && (
+
{description}
+ )}
+
+
+ onOrderChange(moveItem(order, index, index - 1))}
+ >
+
+
+ onOrderChange(moveItem(order, index, index + 1))}
+ >
+
+
+ {onEnabledChange && (
+ onEnabledChange(type, event.target.checked)}
+ />
+ )}
+
+
+ );
+ })}
+
+ );
+};
diff --git a/examples/vite/src/CommandModeAttachmentSelector.tsx b/examples/vite/src/CommandModeAttachmentSelector.tsx
index 080856e7f..1a3357eaf 100644
--- a/examples/vite/src/CommandModeAttachmentSelector.tsx
+++ b/examples/vite/src/CommandModeAttachmentSelector.tsx
@@ -1,11 +1,15 @@
+import { useMemo } from 'react';
import type { TextComposerState } from 'stream-chat';
import {
type AttachmentSelectorProps,
AttachmentSelector as DefaultAttachmentSelector,
+ defaultAttachmentSelectorActionSet,
useMessageComposerController,
useStateStore,
} from 'stream-chat-react';
+import { useAppSettingsSelector } from './AppSettings';
+
const textComposerCommandSelector = ({ command }: TextComposerState) => ({ command });
export const CommandModeAttachmentSelector = (props: AttachmentSelectorProps) => {
@@ -14,10 +18,23 @@ export const CommandModeAttachmentSelector = (props: AttachmentSelectorProps) =>
messageComposer.textComposer.state,
textComposerCommandSelector,
);
+ const { disabledPreviewSections } = useAppSettingsSelector((state) => state.composer);
+ const filesDisabled = disabledPreviewSections.includes('attachments');
+ const attachmentSelectorActionSet = useMemo(() => {
+ const actionSet =
+ props.attachmentSelectorActionSet ?? defaultAttachmentSelectorActionSet;
+ // A composer without the Files preview offers no File option. Polls and location are gated
+ // through the composer config instead (see the setup function in App.tsx), which the SDK's
+ // own selector filter already reads.
+ return filesDisabled
+ ? actionSet.filter(({ type }) => type !== 'uploadFile')
+ : actionSet;
+ }, [filesDisabled, props.attachmentSelectorActionSet]);
return (
Composer. */
+export const ConfigurableComposerPreviews = () => {
+ const { disabledPreviewSections, previewSectionOrder } = useAppSettingsSelector(
+ (state) => state.composer,
+ );
+ const previewSet = useMemo(
+ () => buildPreviewSet(previewSectionOrder, disabledPreviewSections),
+ [disabledPreviewSections, previewSectionOrder],
+ );
+
+ return ;
+};
+
+/** The record button, gone while the voice recording preview is left out of the composer. */
+export const ConfigurableStartRecordingAudioButton = forwardRef<
+ HTMLButtonElement,
+ StartRecordingAudioButtonProps
+>(function ConfigurableStartRecordingAudioButton(props, ref) {
+ const { disabledPreviewSections } = useAppSettingsSelector((state) => state.composer);
+
+ if (disabledPreviewSections.includes('voiceRecording')) return null;
+
+ return ;
+});
diff --git a/examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx b/examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx
new file mode 100644
index 000000000..a75562ef2
--- /dev/null
+++ b/examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx
@@ -0,0 +1,77 @@
+import { flushSync } from 'react-dom';
+import {
+ asDynamicKey,
+ IconPoll,
+ PollCreationDialogControls,
+ type PollCreationDialogControlsProps,
+ Prompt,
+ useCanCreatePoll,
+ useMessageComposerContext,
+ useMessageComposerController,
+ useTranslationContext,
+} from 'stream-chat-react';
+
+import { useAppSettingsSelector } from '../AppSettings';
+
+/**
+ * Creates the poll without sending it: the poll waits in the composer (`state.pollId`), shows in
+ * its Poll preview, and goes out with the next message's text and attachments.
+ */
+const AttachPollControls = ({ close }: PollCreationDialogControlsProps) => {
+ const { t } = useTranslationContext();
+ const { textareaRef } = useMessageComposerContext();
+ const messageComposer = useMessageComposerController();
+ const canCreatePoll = useCanCreatePoll();
+
+ return (
+
+
+
+ {t('common.cancel.label', 'Cancel')}
+
+ {
+ // As in the default controls: close and focus the composer synchronously, so focus
+ // does not blip through the attachment selector's trigger.
+ flushSync(() => close());
+ textareaRef?.current?.focus();
+ try {
+ await messageComposer.createPoll();
+ } catch {
+ // `createPoll()` already reported the failure as a notification.
+ }
+ }}
+ type='submit'
+ >
+
+ {t(
+ asDynamicKey('viteExample.pollCreationDialog.attachPoll.text'),
+ 'Attach poll',
+ )}
+
+
+
+ );
+};
+
+/**
+ * While the composer shows the Poll preview (Settings -> Composer), the dialog attaches the poll
+ * to the message being composed instead of sending it on its own.
+ */
+export const ConfigurablePollCreationDialogControls = (
+ props: PollCreationDialogControlsProps,
+) => {
+ const { disabledPreviewSections } = useAppSettingsSelector((state) => state.composer);
+
+ return disabledPreviewSections.includes('poll') ? (
+
+ ) : (
+
+ );
+};
diff --git a/examples/vite/src/ComposerPreviews/PollPreview.scss b/examples/vite/src/ComposerPreviews/PollPreview.scss
new file mode 100644
index 000000000..202eb97d8
--- /dev/null
+++ b/examples/vite/src/ComposerPreviews/PollPreview.scss
@@ -0,0 +1,87 @@
+// The composer's poll preview (PollPreview.tsx), styled like the SDK's other composer previews.
+.app__poll-preview {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ align-items: start;
+ justify-content: space-around;
+ gap: var(--str-chat__spacing-xs);
+ padding-inline: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm);
+ padding-block: var(--str-chat__spacing-xs);
+ background-color: var(--str-chat__chat-bg-outgoing);
+ border-radius: var(--str-chat__message-bubble-radius-attachment);
+}
+
+.app__poll-preview__image {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ overflow: hidden;
+ border-radius: var(--str-chat__radius-md);
+ background-color: var(--str-chat__background-core-overlay-light);
+
+ .str-chat__icon {
+ width: 20px;
+ height: 20px;
+ }
+}
+
+.app__poll-preview__data {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ justify-content: center;
+ min-width: 0;
+ height: 100%;
+ gap: 2px;
+}
+
+.app__poll-preview__title,
+.app__poll-preview__options-list {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.app__poll-preview__title {
+ font: var(--str-chat__font-metadata-emphasis);
+}
+
+.app__poll-preview__options {
+ display: flex;
+ gap: var(--str-chat__spacing-xxs);
+ min-width: 0;
+ font: var(--str-chat__font-metadata-default);
+}
+
+.app__poll-preview__options-count {
+ flex-shrink: 0;
+ color: var(--str-chat__text-primary);
+}
+
+// A poll holds up to 100 options; only the first few are named and the line still ellipsizes.
+.app__poll-preview__options-list {
+ min-width: 0;
+ color: var(--str-chat__text-secondary);
+}
+
+.app__poll-preview__settings {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--str-chat__spacing-xxs);
+ margin: 2px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.app__poll-preview__setting {
+ padding: 0 var(--str-chat__spacing-xs);
+ border-radius: var(--str-chat__radius-max);
+ background-color: var(--str-chat__background-core-overlay-light);
+ font: var(--str-chat__font-metadata-default);
+ white-space: nowrap;
+}
diff --git a/examples/vite/src/ComposerPreviews/PollPreview.tsx b/examples/vite/src/ComposerPreviews/PollPreview.tsx
new file mode 100644
index 000000000..682d5d82e
--- /dev/null
+++ b/examples/vite/src/ComposerPreviews/PollPreview.tsx
@@ -0,0 +1,205 @@
+import { useEffect, useState } from 'react';
+import type { MessageComposerState, Poll, PollState } from 'stream-chat';
+import {
+ asDynamicKey,
+ type MessageComposerPreviewSetItem,
+ RemoveAttachmentPreviewButton,
+ useChatContext,
+ useComponentContextIcons,
+ useMessageComposerController,
+ useStateStore,
+ useTranslationContext,
+} from 'stream-chat-react';
+
+const messageComposerStateSelector = ({ pollId }: MessageComposerState) => ({ pollId });
+
+const pollStateSelector = ({
+ allow_answers,
+ allow_user_suggested_options,
+ enforce_unique_vote,
+ max_votes_allowed,
+ name,
+ options,
+ voting_visibility,
+}: PollState) => ({
+ allow_answers,
+ allow_user_suggested_options,
+ enforce_unique_vote,
+ max_votes_allowed,
+ name,
+ options,
+ voting_visibility,
+});
+
+/** Stream API error code for a resource that does not exist. */
+const DOES_NOT_EXIST_ERROR_CODE = 16;
+
+/** Options named in the preview. A poll holds up to 100, and the rest are only counted. */
+const LISTED_OPTION_COUNT = 3;
+
+const PollPreviewData = ({ poll }: { poll: Poll }) => {
+ const { t } = useTranslationContext();
+ const {
+ allow_answers,
+ allow_user_suggested_options,
+ enforce_unique_vote,
+ max_votes_allowed,
+ name,
+ options = [],
+ voting_visibility,
+ } = useStateStore(poll.state, pollStateSelector);
+
+ const listedOptions = options
+ .slice(0, LISTED_OPTION_COUNT)
+ .map(({ text }) => text)
+ .join(', ');
+ const unlistedOptionCount = options.length - LISTED_OPTION_COUNT;
+
+ const settings = [
+ !enforce_unique_vote &&
+ (max_votes_allowed
+ ? t(asDynamicKey('viteExample.pollPreview.maxVotes.text'), {
+ count: max_votes_allowed,
+ defaultValue_one: 'Up to {{ count }} vote',
+ defaultValue_other: 'Up to {{ count }} votes',
+ })
+ : t(
+ asDynamicKey('viteExample.pollPreview.multipleVotes.text'),
+ 'Multiple votes',
+ )),
+ voting_visibility === 'anonymous' &&
+ t(asDynamicKey('viteExample.pollPreview.anonymous.text'), 'Anonymous'),
+ allow_user_suggested_options &&
+ t(
+ asDynamicKey('viteExample.pollPreview.suggestOptions.text'),
+ 'Others can add options',
+ ),
+ allow_answers && t(asDynamicKey('viteExample.pollPreview.comments.text'), 'Comments'),
+ ].filter((setting): setting is string => !!setting);
+
+ return (
+
+
+ {name}
+
+ {options.length > 0 && (
+
+
+ {t(asDynamicKey('viteExample.pollPreview.optionCount.text'), {
+ count: options.length,
+ defaultValue_one: '{{ count }} option',
+ defaultValue_other: '{{ count }} options',
+ })}
+
+
+ {unlistedOptionCount > 0
+ ? t(asDynamicKey('viteExample.pollPreview.listedOptions.withMore.text'), {
+ count: unlistedOptionCount,
+ defaultValue_one: '{{ options }} and {{ count }} more',
+ defaultValue_other: '{{ options }} and {{ count }} more',
+ options: listedOptions,
+ })
+ : listedOptions}
+
+
+ )}
+ {settings.length > 0 && (
+
+ {settings.map((setting) => (
+
+ {setting}
+
+ ))}
+
+ )}
+
+ );
+};
+
+/**
+ * Preview of the poll the composer holds (`messageComposer.state.pollId`), which goes out with
+ * the message. Shows the poll's question, options and settings, fetching a poll that is not in
+ * `client.polls` yet, e.g. one restored from a draft.
+ */
+export const PollPreview = () => {
+ const { client } = useChatContext();
+ const { t } = useTranslationContext();
+ const { IconPoll } = useComponentContextIcons();
+ const messageComposer = useMessageComposerController();
+ const { pollId } = useStateStore(messageComposer.state, messageComposerStateSelector);
+ const [fetchedPoll, setFetchedPoll] = useState();
+
+ const cachedPoll = pollId ? client.polls.fromState(pollId) : undefined;
+ const poll = cachedPoll ?? (fetchedPoll?.id === pollId ? fetchedPoll : undefined);
+
+ // Stopgap: a restored draft carries only `poll_id`, and `client.polls` is not reactive, so the
+ // preview fetches the poll itself. Goes away once stream-chat keeps polls in a reactive store.
+ useEffect(() => {
+ if (!pollId || client.polls.fromState(pollId)) return;
+ let cancelled = false;
+ client.polls
+ .getPoll(pollId)
+ .then((poll) => {
+ if (!cancelled) setFetchedPoll(poll);
+ })
+ .catch((error: { code?: number; status?: number }) => {
+ // A poll that no longer exists (e.g. a restored draft's deleted poll) would fail the send,
+ // so it is detached. Any other failure keeps the placeholder; the poll still goes out.
+ if (
+ cancelled ||
+ (error?.code !== DOES_NOT_EXIST_ERROR_CODE && error?.status !== 404)
+ )
+ return;
+ if (messageComposer.pollId === pollId) {
+ messageComposer.state.partialNext({ pollId: null });
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [client, messageComposer, pollId]);
+
+ if (!pollId) return null;
+
+ return (
+
+
+
+
+ {poll ? (
+
+ ) : (
+
+
{t('common.poll.label', 'Poll')}
+
+ )}
+
+ // Detached, not deleted: the composer does not know whether it created the poll or
+ // whether the poll was sent, e.g. a `poll_id` restored from a draft.
+ messageComposer.state.partialNext({ pollId: null })
+ }
+ />
+
+ );
+};
+
+/** The composer preview item for `PollPreview`, added to the SDK's default preview set. */
+export const pollPreviewItem: MessageComposerPreviewSetItem = {
+ Component: PollPreview,
+ // An edited message keeps its poll; it cannot be replaced or removed.
+ isVisible: ({ editedMessage, pollId }) => !editedMessage && !!pollId,
+ type: 'poll',
+};
diff --git a/examples/vite/src/ComposerPreviews/previewSections.ts b/examples/vite/src/ComposerPreviews/previewSections.ts
new file mode 100644
index 000000000..db97ca138
--- /dev/null
+++ b/examples/vite/src/ComposerPreviews/previewSections.ts
@@ -0,0 +1,75 @@
+import {
+ defaultMessageComposerPreviewSet,
+ type DefaultMessageComposerPreviewType,
+ type MessageComposerPreviewSetItem,
+} from 'stream-chat-react';
+
+import { pollPreviewItem } from './PollPreview';
+
+type PreviewSectionInfo = {
+ label: string;
+ /** What else leaving the section out turns off, so the composer never holds what it cannot show. */
+ removes: string;
+};
+
+/** The SDK's default previews plus this app's poll preview, which the SDK does not ship. */
+const examplePreviewSet: MessageComposerPreviewSetItem[] = [
+ ...defaultMessageComposerPreviewSet,
+ pollPreviewItem,
+];
+
+/** The preview types the Composer settings list. */
+export type PreviewSectionType = DefaultMessageComposerPreviewType | 'poll';
+
+export const PREVIEW_SECTION_INFO: Record = {
+ attachments: {
+ label: 'Files',
+ removes:
+ 'The File option in the attachment selector; dropped and pasted files are rejected.',
+ },
+ editedMessage: {
+ label: 'Edited message',
+ removes: 'The Edit and Edit inline message actions.',
+ },
+ linkPreviews: {
+ label: 'Link previews',
+ removes: 'Link scraping while typing.',
+ },
+ location: {
+ label: 'Location',
+ removes: 'The Location option in the attachment selector.',
+ },
+ poll: {
+ label: 'Poll',
+ removes:
+ "The Poll option in the attachment selector. This app's own preview (PollPreview.tsx), not one the SDK ships. While shown, the poll dialog attaches the poll to the message instead of sending it.",
+ },
+ quotedMessage: {
+ label: 'Quoted message',
+ removes: 'The Quote message action.',
+ },
+ voiceRecording: {
+ label: 'Voice recording',
+ removes: 'The record button.',
+ },
+};
+
+const DEFAULT_TYPES = examplePreviewSet.map(({ type }) => type);
+
+/**
+ * The stored order, minus types the SDK no longer ships, plus default types it does not list yet
+ * (appended), so the setting survives SDK upgrades.
+ */
+export const resolvePreviewSectionOrder = (order: string[]) => [
+ ...order.filter((type) => DEFAULT_TYPES.includes(type)),
+ ...DEFAULT_TYPES.filter((type) => !order.includes(type)),
+];
+
+/** The app's preview set reordered and filtered by the Composer settings. */
+export const buildPreviewSet = (
+ order: string[],
+ disabled: string[],
+): MessageComposerPreviewSetItem[] =>
+ resolvePreviewSectionOrder(order)
+ .filter((type) => !disabled.includes(type))
+ .flatMap((type) => examplePreviewSet.filter((item) => item.type === type));
diff --git a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx
index e23aeecfa..eaf2ba282 100644
--- a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx
+++ b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx
@@ -19,6 +19,7 @@ import {
IconNotification,
MessageActions,
type MessageActionSetItem,
+ type MessageActionVisibilityFilterParams,
modalDialogManagerId,
SwitchField,
useComponentContext,
@@ -369,9 +370,29 @@ export const ConfigurableMessageActions = (
(state) => state.messageActions,
);
const surfaceActions = customMessageActions[surface];
+ const { disabledPreviewSections } = useAppSettingsSelector((state) => state.composer);
+ // A composer without the quoted or edited message preview has nowhere to show what these
+ // actions put into it, so they go along with the preview (Settings -> Composer).
+ const quoteDisabled = disabledPreviewSections.includes('quotedMessage');
+ const editDisabled = disabledPreviewSections.includes('editedMessage');
+ const pollDisabled = disabledPreviewSections.includes('poll');
const customDeleteEnabled = surfaceActions.delete.enableOptionConfiguration;
const configurableActionSet = useMemo(() => {
- const actionSet = props.messageActionSet ?? defaultMessageActionSet;
+ const baseActionSet = props.messageActionSet ?? defaultMessageActionSet;
+ // While the Poll preview shows, a poll goes out with the message's text and attachments, so
+ // the message stays editable. Only the edit item's own rule changes: the SDK still requires
+ // the update-message capability.
+ const actionSet = pollDisabled
+ ? baseActionSet
+ : baseActionSet.map((item) =>
+ item.placement !== 'quick-dropdown-toggle' && item.type === 'edit'
+ ? {
+ ...item,
+ isVisible: ({ message }: MessageActionVisibilityFilterParams) =>
+ message.command !== 'giphy',
+ }
+ : item,
+ );
const actionOverrides: Record<
SupportedCustomMessageActionType,
Omit
@@ -438,16 +459,24 @@ export const ConfigurableMessageActions = (
overrides,
});
+ const disabledActionTypes = [
+ ...surfaceActions.disabledActionTypes,
+ ...(quoteDisabled ? ['quote'] : []),
+ ...(editDisabled ? ['edit', 'editInline'] : []),
+ ];
+
// Turning a default action off is just dropping it from the set; the SDK's base filter still
// has the last word on whatever remains.
- if (!surfaceActions.disabledActionTypes.length) return withOverrides;
+ if (!disabledActionTypes.length) return withOverrides;
return withOverrides.filter(
- (item) =>
- !('type' in item) || !surfaceActions.disabledActionTypes.includes(item.type),
+ (item) => !('type' in item) || !disabledActionTypes.includes(item.type),
);
}, [
customDeleteEnabled,
+ editDisabled,
+ pollDisabled,
+ quoteDisabled,
surfaceActions.disabledActionTypes,
surfaceActions.inlineEdit,
surfaceActions.markOwnUnread,
diff --git a/examples/vite/src/MessageBubble/ConfigurableMessageBubbleContent.tsx b/examples/vite/src/MessageBubble/ConfigurableMessageBubbleContent.tsx
new file mode 100644
index 000000000..5f72fe2e5
--- /dev/null
+++ b/examples/vite/src/MessageBubble/ConfigurableMessageBubbleContent.tsx
@@ -0,0 +1,13 @@
+import { useMemo } from 'react';
+import { MessageBubbleContent } from 'stream-chat-react';
+
+import { useAppSettingsSelector } from '../AppSettings';
+import { buildBubbleContentSet } from './bubbleContent';
+
+/** The message bubble's content, in the order set in Settings -> Message. */
+export const ConfigurableMessageBubbleContent = () => {
+ const { contentOrder } = useAppSettingsSelector((state) => state.messageBubble);
+ const contentSet = useMemo(() => buildBubbleContentSet(contentOrder), [contentOrder]);
+
+ return ;
+};
diff --git a/examples/vite/src/MessageBubble/bubbleContent.ts b/examples/vite/src/MessageBubble/bubbleContent.ts
new file mode 100644
index 000000000..f5f0f676f
--- /dev/null
+++ b/examples/vite/src/MessageBubble/bubbleContent.ts
@@ -0,0 +1,29 @@
+import {
+ defaultMessageBubbleContentSet,
+ type DefaultMessageBubbleContentType,
+ type MessageBubbleContentSetItem,
+} from 'stream-chat-react';
+
+export const BUBBLE_CONTENT_LABELS: Record = {
+ attachments: 'Attachments',
+ poll: 'Poll',
+ quotedMessage: 'Quoted message',
+ text: 'Text',
+};
+
+const DEFAULT_TYPES = defaultMessageBubbleContentSet.map(({ type }) => type);
+
+/**
+ * The stored order, minus types the SDK no longer ships, plus default types it does not list yet
+ * (appended), so the setting survives SDK upgrades.
+ */
+export const resolveBubbleContentOrder = (order: string[]) => [
+ ...order.filter((type) => DEFAULT_TYPES.includes(type)),
+ ...DEFAULT_TYPES.filter((type) => !order.includes(type)),
+];
+
+/** `defaultMessageBubbleContentSet` in the order set in Settings -> Message. */
+export const buildBubbleContentSet = (order: string[]): MessageBubbleContentSetItem[] =>
+ resolveBubbleContentOrder(order).flatMap((type) =>
+ defaultMessageBubbleContentSet.filter((item) => item.type === type),
+ );
diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss
index 14714fc81..a63afc1da 100644
--- a/examples/vite/src/index.scss
+++ b/examples/vite/src/index.scss
@@ -10,6 +10,7 @@
@import url('./CustomMessageActions/CustomMessageActions.scss')
layer(stream-app-overrides);
@import url('./InlineEditMessage/InlineEditMessage.scss') layer(stream-app-overrides);
+@import url('./ComposerPreviews/PollPreview.scss') layer(stream-app-overrides);
@import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides);
@import url('./AccessibilityNavigation/ReturnToSkipNavigation.scss')
layer(stream-app-overrides);
diff --git a/package.json b/package.json
index 8b4b45972..5e9422bfb 100644
--- a/package.json
+++ b/package.json
@@ -144,7 +144,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
- "stream-chat": "^10.0.0-rc.28"
+ "stream-chat": "^10.0.0-rc.29"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
@@ -210,7 +210,7 @@
"react-dom": "^19.2.6",
"sass": "^1.100.0",
"semantic-release": "^25.0.3",
- "stream-chat": "10.0.0-rc.28",
+ "stream-chat": "10.0.0-rc.29",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.1.3",
diff --git a/src/components/Message/MessageBubbleContent.defaults.tsx b/src/components/Message/MessageBubbleContent.defaults.tsx
new file mode 100644
index 000000000..7a23203ed
--- /dev/null
+++ b/src/components/Message/MessageBubbleContent.defaults.tsx
@@ -0,0 +1,79 @@
+/* eslint-disable sort-keys */
+import React, { useMemo } from 'react';
+
+import { useChatContext } from '../../context/ChatContext';
+import { useComponentContext } from '../../context/ComponentContext';
+import { useMessageContext } from '../../context/MessageContext';
+import { Attachment as DefaultAttachment } from '../Attachment';
+import { Poll } from '../Poll';
+import type { MessageBubbleContentSetItem } from './MessageBubbleContent';
+import { MessageText } from './MessageText';
+import { QuotedMessage as DefaultQuotedMessage } from './QuotedMessage';
+import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
+
+const DefaultMessageBubbleContentComponents = {
+ QuotedMessage() {
+ const { QuotedMessage = DefaultQuotedMessage } = useComponentContext();
+ return ;
+ },
+ Poll() {
+ const { client } = useChatContext();
+ const { message } = useMessageContext();
+ const poll = message.poll_id && client.polls.fromState(message.poll_id);
+
+ return poll ? : null;
+ },
+ Attachments() {
+ const { Attachment = DefaultAttachment } = useComponentContext();
+ const { handleAction, message } = useMessageContext();
+ // A shared location renders as the first attachment.
+ const attachments = useMemo(
+ () =>
+ message.shared_location
+ ? [message.shared_location, ...(message.attachments ?? [])]
+ : (message.attachments ?? []),
+ [message.attachments, message.shared_location],
+ );
+
+ return ;
+ },
+ Text() {
+ const { StreamedMessageText = DefaultStreamedMessageText } = useComponentContext();
+ const { isMessageAIGenerated, message, renderText } = useMessageContext();
+
+ return isMessageAIGenerated?.(message) ? (
+
+ ) : (
+
+ );
+ },
+};
+
+/**
+ * What `MessageBubbleContent` renders by default, in render order. Filter, reorder or extend it
+ * and pass the result as `messageBubbleContentSet`.
+ */
+export const defaultMessageBubbleContentSet: MessageBubbleContentSetItem[] = [
+ {
+ Component: DefaultMessageBubbleContentComponents.QuotedMessage,
+ isVisible: ({ message }) => !!message.quoted_message,
+ type: 'quotedMessage',
+ },
+ {
+ Component: DefaultMessageBubbleContentComponents.Poll,
+ isVisible: ({ message }) => !!message.poll_id,
+ type: 'poll',
+ },
+ {
+ Component: DefaultMessageBubbleContentComponents.Attachments,
+ isVisible: ({ message }) =>
+ !!message.shared_location || !!message.attachments?.length,
+ type: 'attachments',
+ },
+ {
+ Component: DefaultMessageBubbleContentComponents.Text,
+ // Streamed text renders while the AI response is still empty.
+ isVisible: ({ isAIGenerated, message }) => isAIGenerated || !!message.text,
+ type: 'text',
+ },
+];
diff --git a/src/components/Message/MessageBubbleContent.tsx b/src/components/Message/MessageBubbleContent.tsx
new file mode 100644
index 000000000..84f14f2bd
--- /dev/null
+++ b/src/components/Message/MessageBubbleContent.tsx
@@ -0,0 +1,63 @@
+import React, { useMemo } from 'react';
+import type { LocalMessage } from 'stream-chat';
+
+import { useMessageContext } from '../../context/MessageContext';
+import { defaultMessageBubbleContentSet } from './MessageBubbleContent.defaults';
+
+/**
+ * The content types {@link defaultMessageBubbleContentSet} ships, listed for autocomplete. Any
+ * string is accepted, so custom content names itself.
+ */
+export type DefaultMessageBubbleContentType =
+ | 'attachments'
+ | 'poll'
+ | 'quotedMessage'
+ | 'text';
+
+/** What decides which parts of a message bubble show. */
+export type MessageBubbleContentVisibilityFilterParams = {
+ /** Whether `isMessageAIGenerated` marks the message as AI generated (streamed text). */
+ isAIGenerated: boolean;
+ message: LocalMessage;
+};
+
+export type MessageBubbleContentSetItem = {
+ /** Rendered without props while `isVisible` holds. Read the message with `useMessageContext()`. */
+ Component: React.ComponentType;
+ /** Whether the content shows for the message. */
+ isVisible: (state: MessageBubbleContentVisibilityFilterParams) => boolean;
+ /** Names the content; unique within a set. */
+ type: DefaultMessageBubbleContentType | (string & {});
+};
+
+export type MessageBubbleContentProps = {
+ /** Content in render order. Defaults to {@link defaultMessageBubbleContentSet}. */
+ messageBubbleContentSet?: MessageBubbleContentSetItem[];
+};
+
+/**
+ * Renders what a message bubble holds (quoted message, poll, attachments, text) in the order of
+ * `messageBubbleContentSet`, skipping what the message does not have.
+ */
+export const MessageBubbleContent = ({
+ messageBubbleContentSet = defaultMessageBubbleContentSet,
+}: MessageBubbleContentProps) => {
+ const { isMessageAIGenerated, message } = useMessageContext();
+ const isAIGenerated = !!isMessageAIGenerated?.(message);
+
+ const visibleContentSet = useMemo(
+ () =>
+ messageBubbleContentSet.filter((item) =>
+ item.isVisible({ isAIGenerated, message }),
+ ),
+ [isAIGenerated, message, messageBubbleContentSet],
+ );
+
+ return (
+ <>
+ {visibleContentSet.map(({ Component, type }) => (
+
+ ))}
+ >
+ );
+};
diff --git a/src/components/Message/MessageUI.tsx b/src/components/Message/MessageUI.tsx
index 702c9005d..058386a2a 100644
--- a/src/components/Message/MessageUI.tsx
+++ b/src/components/Message/MessageUI.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from 'react';
+import React, { useState } from 'react';
import clsx from 'clsx';
import { MessageBouncePrompt as DefaultMessageBouncePrompt } from '../MessageBounce';
import { MessageDeletedBubble as DefaultMessageDeletedBubble } from './MessageDeletedBubble';
@@ -6,10 +6,8 @@ import { MessageBlocked as DefaultMessageBlocked } from './MessageBlocked';
import { MessageActions as DefaultMessageActions } from '../MessageActions';
import { MessageRepliesCountButton as DefaultMessageRepliesCountButton } from './MessageRepliesCountButton';
import { MessageStatus as DefaultMessageStatus } from './MessageStatus';
-import { MessageText } from './MessageText';
import { MessageEditedIndicator as DefaultMessageEditedIndicator } from './MessageEditedIndicator';
import { MessageTimestamp as DefaultMessageTimestamp } from './MessageTimestamp';
-import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
import { isDateSeparatorMessage } from '../MessageList';
import { MessageAlsoSentInChannelIndicator as DefaultMessageAlsoSentInChannelIndicator } from './MessageAlsoSentInChannelIndicator';
import { ReminderNotification as DefaultReminderNotification } from './ReminderNotification';
@@ -32,21 +30,19 @@ import {
} from './utils';
import { Avatar as DefaultAvatar } from '../Avatar';
-import { Attachment as DefaultAttachment } from '../Attachment';
-import { Poll } from '../Poll';
import { MessageReactions as DefaultMessageReactions } from '../Reactions';
import { MessageBounceModal } from '../MessageBounce/MessageBounceModal';
import { useComponentContext } from '../../context/ComponentContext';
import type { MessageContextValue } from '../../context/MessageContext';
-import { useMessageContext } from '../../context/MessageContext';
+import { MessageProvider, useMessageContext } from '../../context/MessageContext';
-import { useChannel, useChatContext, useTranslationContext } from '../../context';
+import { useChannel, useTranslationContext } from '../../context';
import { useThreadContext } from '../Threads';
import type { MessageUIComponentProps } from './types';
import { PinIndicator as DefaultPinIndicator } from './PinIndicator';
-import { QuotedMessage as DefaultQuotedMessage } from './QuotedMessage';
import { MessageBubble } from './MessageBubble';
+import { MessageBubbleContent as DefaultMessageBubbleContent } from './MessageBubbleContent';
import { ErrorBadge } from '../Badge';
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils';
@@ -56,31 +52,28 @@ const MessageUIWithContext = ({
endOfGroup,
firstOfGroup,
groupedByUser,
- handleAction,
highlighted,
isMessageAIGenerated,
isMyMessage,
message,
onUserClick,
onUserHover,
- renderText,
showAvatar = 'incoming',
}: MessageUIWithContextProps) => {
const channel = useChannel();
const threadInstance = useThreadContext();
- const { client } = useChatContext();
const { t } = useTranslationContext();
const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false);
const reminder = useMessageReminder(message.id);
const {
- Attachment = DefaultAttachment,
Avatar = DefaultAvatar,
extractDisplayInfo = defaultExtractDisplayInfo,
MessageActions = DefaultMessageActions,
MessageAlsoSentInChannelIndicator = DefaultMessageAlsoSentInChannelIndicator,
MessageBlocked = DefaultMessageBlocked,
MessageBouncePrompt = DefaultMessageBouncePrompt,
+ MessageBubbleContent = DefaultMessageBubbleContent,
MessageDeleted,
MessageDeletedBubble = DefaultMessageDeletedBubble,
MessageEditedIndicator = DefaultMessageEditedIndicator,
@@ -90,27 +83,12 @@ const MessageUIWithContext = ({
MessageTimestamp = DefaultMessageTimestamp,
MessageTranslationIndicator = DefaultMessageTranslationIndicator,
PinIndicator = DefaultPinIndicator,
- QuotedMessage = DefaultQuotedMessage,
ReminderNotification = DefaultReminderNotification,
- StreamedMessageText = DefaultStreamedMessageText,
} = useComponentContext();
- const isAIGenerated = useMemo(
- () => isMessageAIGenerated?.(message),
- [isMessageAIGenerated, message],
- );
+ const isAIGenerated = isMessageAIGenerated?.(message);
const isDeleted = isMessageDeleted(message);
- const finalAttachments = useMemo(
- () =>
- !message.shared_location && !message.attachments
- ? []
- : !message.shared_location
- ? message.attachments
- : [message.shared_location, ...(message.attachments ?? [])],
- [message],
- );
-
if (isDateSeparatorMessage(message)) {
return null;
}
@@ -123,11 +101,12 @@ const MessageUIWithContext = ({
return ;
}
- const poll = message.poll_id && client.polls.fromState(message.poll_id);
-
const memberCount = Object.keys(channel?.state?.members ?? {}).length;
const hasAttachment = !isDeleted && messageHasAttachments(message);
- const hasSingleAttachment = !isDeleted && messageHasSingleAttachment(message);
+ const hasPoll = !isDeleted && !!message.poll_id;
+ // A poll shares the bubble, so an attachment next to it is not laid out as the only content.
+ const hasSingleAttachment =
+ !isDeleted && !hasPoll && messageHasSingleAttachment(message);
const hasGiphyAttachment = !isDeleted && messageHasGiphyAttachment(message);
const hasReactions = !isDeleted && messageHasReactions(message);
const hasQuotedMessage = !isDeleted && messageHasQuotedMessage(message);
@@ -148,6 +127,7 @@ const MessageUIWithContext = ({
'str-chat__message--has-attachment': hasAttachment,
'str-chat__message--has-giphy-attachment': hasGiphyAttachment,
'str-chat__message--has-no-text': !message.text,
+ 'str-chat__message--has-poll': hasPoll,
'str-chat__message--has-quoted-message': hasQuotedMessage,
'str-chat__message--has-single-attachment': hasSingleAttachment,
'str-chat__message--has-text': !!message.text,
@@ -238,19 +218,7 @@ const MessageUIWithContext = ({
) : (
<>
- {poll && }
- {message.quoted_message && }
- {finalAttachments?.length ? (
-
- ) : null}
- {isAIGenerated ? (
-
- ) : (
-
- )}
+
{hasReactions &&
}
@@ -286,6 +254,14 @@ const MemoizedMessageUI = React.memo(
/** Default message UI; reads its data and handlers from `MessageContext`. */
export const MessageUI = (props: MessageUIComponentProps) => {
const messageContext = useMessageContext();
+ const messageUI =
;
- return
;
+ // Props passed to `MessageUI` override the context for everything that reads it, the bubble
+ // content included. Provided outside the memo, so the context stays live, e.g. on a translation
+ // toggle the memo's comparator does not look at.
+ if (!Object.keys(props).length) return messageUI;
+
+ return (
+
{messageUI}
+ );
};
diff --git a/src/components/Message/__tests__/MessageBubbleContent.test.tsx b/src/components/Message/__tests__/MessageBubbleContent.test.tsx
new file mode 100644
index 000000000..978f29c6c
--- /dev/null
+++ b/src/components/Message/__tests__/MessageBubbleContent.test.tsx
@@ -0,0 +1,156 @@
+import React from 'react';
+import { act, cleanup, render } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat';
+
+import { axe } from '../../../../axe-helper';
+import {
+ generateImageAttachment,
+ generateMessage,
+ generatePoll,
+ generateUser,
+ initClientWithChannels,
+} from '../../../mock-builders';
+import { Chat } from '../../Chat';
+import { Channel } from '../../Channel';
+import { WithComponents } from '../../../context';
+import { Message } from '../Message';
+import {
+ MessageBubbleContent,
+ type MessageBubbleContentSetItem,
+} from '../MessageBubbleContent';
+import { defaultMessageBubbleContentSet } from '../MessageBubbleContent.defaults';
+import type { MessageProps } from '../types';
+
+const user = generateUser({ id: 'me' });
+
+const CONTENT_SELECTORS: Record
= {
+ attachments: '.str-chat__attachment-list',
+ custom: '[data-testid="custom-content"]',
+ poll: '.str-chat__poll',
+ quotedMessage: '.str-chat__quoted-message-preview',
+ text: '.str-chat__message-text',
+};
+
+/** The bubble's content types, in DOM order. */
+const bubbleContentOrder = (container: HTMLElement) => {
+ const bubble = container.querySelector('.str-chat__message-bubble');
+ if (!bubble) return [];
+ return Array.from(bubble.children).flatMap((child) => {
+ const type = Object.entries(CONTENT_SELECTORS).find(([, selector]) =>
+ child.matches(selector),
+ )?.[0];
+ return type ? [type] : [];
+ });
+};
+
+const setup = async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({ customUser: user });
+ const poll = generatePoll({ id: 'bubble-poll', name: 'Which venue?' });
+ client.polls.hydratePollCache([fromPartial({ poll })]);
+ const quoted = generateMessage({ text: 'the quoted one', user });
+ const message = generateMessage({
+ attachments: [generateImageAttachment()],
+ poll,
+ poll_id: poll.id,
+ quoted_message: quoted,
+ quoted_message_id: quoted.id,
+ text: 'please vote',
+ user,
+ }) as unknown as LocalMessage;
+ return { channel, client, message };
+};
+
+const renderMessage = async ({
+ channel,
+ client,
+ components = {},
+ message,
+}: {
+ channel: ChannelType;
+ client: StreamChat;
+ components?: Parameters[0]['overrides'];
+ message: LocalMessage;
+}) => {
+ let result!: ReturnType;
+ await act(() => {
+ result = render(
+
+
+
+ ({ message, threadList: false })} />
+
+
+ ,
+ );
+ });
+ return result;
+};
+
+describe('MessageBubbleContent', () => {
+ afterEach(cleanup);
+
+ it('renders a poll next to the quoted message, attachments and text, quote first', async () => {
+ const { container } = await renderMessage(await setup());
+
+ expect(bubbleContentOrder(container)).toEqual([
+ 'quotedMessage',
+ 'poll',
+ 'attachments',
+ 'text',
+ ]);
+ });
+
+ it('marks the message as having a poll, and not as a single-attachment message', async () => {
+ const { container } = await renderMessage(await setup());
+ const root = container.querySelector('.str-chat__message');
+
+ expect(root).toHaveClass('str-chat__message--has-poll');
+ expect(root).not.toHaveClass('str-chat__message--has-single-attachment');
+ });
+
+ it('skips content the message does not have', async () => {
+ const { channel, client } = await setup();
+ const message = generateMessage({
+ text: 'just text',
+ user,
+ }) as unknown as LocalMessage;
+ const { container } = await renderMessage({ channel, client, message });
+
+ expect(bubbleContentOrder(container)).toEqual(['text']);
+ });
+
+ it('renders a reordered and extended set installed through the component slot', async () => {
+ const setupResult = await setup();
+ const byType = (type: string) =>
+ defaultMessageBubbleContentSet.find(
+ (item) => item.type === type,
+ ) as MessageBubbleContentSetItem;
+ const CustomContent = () => Custom
;
+ const CustomBubbleContent = () => (
+ true, type: 'custom' },
+ byType('poll'),
+ ]}
+ />
+ );
+
+ const { container } = await renderMessage({
+ ...setupResult,
+ components: { MessageBubbleContent: CustomBubbleContent },
+ });
+
+ expect(bubbleContentOrder(container)).toEqual(['text', 'custom', 'poll']);
+ });
+
+ it('has no accessibility violations', async () => {
+ const { container } = await renderMessage(await setup());
+
+ expect(await axe(container)).toHaveNoViolations();
+ });
+});
diff --git a/src/components/Message/__tests__/MessageText.test.tsx b/src/components/Message/__tests__/MessageText.test.tsx
index 050dd7994..94f856eb8 100644
--- a/src/components/Message/__tests__/MessageText.test.tsx
+++ b/src/components/Message/__tests__/MessageText.test.tsx
@@ -21,6 +21,10 @@ import {
mockTranslationContextValue,
} from '../../../mock-builders';
+import {
+ MessageTranslationViewProvider,
+ useMessageTranslationViewContext,
+} from '../../../context/MessageTranslationViewContext';
import { Channel } from '../../Channel';
import { Chat } from '../../Chat';
import { Attachment } from '../../Attachment';
@@ -433,6 +437,54 @@ describe(' ', () => {
expect(results).toHaveNoViolations();
});
+ it('shows the original text after the translation view is toggled', async () => {
+ const message = generateAliceMessage({
+ i18n: { en_text: 'hello', fr_text: 'bonjour', language: 'fr' },
+ text: 'bonjour',
+ });
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({ customUser: alice });
+ const ShowOriginal = () => {
+ const { setTranslationView } = useMessageTranslationViewContext();
+ return (
+ setTranslationView(message.id, 'original')} type='button'>
+ Show original
+
+ );
+ };
+
+ const { getByRole, getByText } = render(
+
+
+
+ })}
+ >
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ expect(getByText('hello')).toBeInTheDocument();
+ // The memoized message UI does not compare `translationView`; the bubble must still follow it.
+ fireEvent.click(getByRole('button', { name: 'Show original' }));
+ await waitFor(() => expect(getByText('bonjour')).toBeInTheDocument());
+ });
+
it('should show reaction list if message has reactions and detailed reactions are not displayed', async () => {
const reactions = [generateReaction({ user: bob })];
const message = generateAliceMessage({
diff --git a/src/components/Message/hooks/__tests__/useUserRole.test.tsx b/src/components/Message/hooks/__tests__/useUserRole.test.tsx
index d773b548e..7456d21c5 100644
--- a/src/components/Message/hooks/__tests__/useUserRole.test.tsx
+++ b/src/components/Message/hooks/__tests__/useUserRole.test.tsx
@@ -223,6 +223,18 @@ describe('useUserRole custom hook', () => {
},
);
+ it('checks only the permission for canEdit, so a poll message stays editable', async () => {
+ const message = {
+ ...generateMessage({ poll_id: 'poll-1', user: alice }),
+ poll: fromPartial({ id: 'poll-1' }),
+ } as LocalMessage;
+ const { canEdit } = await renderUserRoleHook({
+ channelStateContextValue: { channelCapabilities: { 'update-own-message': true } },
+ message,
+ });
+ expect(canEdit).toBe(true);
+ });
+
describe('canDo flags', () => {
it.each([
[true, true, alice, true],
diff --git a/src/components/Message/hooks/useUserRole.ts b/src/components/Message/hooks/useUserRole.ts
index 9c07f9b12..19b28fcb6 100644
--- a/src/components/Message/hooks/useUserRole.ts
+++ b/src/components/Message/hooks/useUserRole.ts
@@ -24,11 +24,11 @@ export const useUserRole = (message: LocalMessage, disableQuotedMessages?: boole
const isMyMessage = client.userID === message.user?.id;
+ // Permission only. Which messages have editable content (no poll, no giphy) is the `edit`
+ // action's `isVisible` rule in `defaultMessageActionSet`.
const canEdit =
- !message.poll &&
- message.command !== 'giphy' &&
- (channelCapabilities.has('update-any-message') ||
- (isMyMessage && channelCapabilities.has('update-own-message')));
+ channelCapabilities.has('update-any-message') ||
+ (isMyMessage && channelCapabilities.has('update-own-message'));
const canDelete =
channelCapabilities.has('delete-any-message') ||
diff --git a/src/components/Message/index.ts b/src/components/Message/index.ts
index 479ddcc4b..c0c319b19 100644
--- a/src/components/Message/index.ts
+++ b/src/components/Message/index.ts
@@ -1,6 +1,8 @@
export * from './hooks';
export * from './Message';
export * from './MessageBlocked';
+export * from './MessageBubbleContent';
+export * from './MessageBubbleContent.defaults';
export * from './MessageDeletedBubble';
export * from './MessageAlsoSentInChannelIndicator';
export * from './MessageRepliesCountButton';
diff --git a/src/components/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx
index 58c3e6e33..9908d9abe 100644
--- a/src/components/MessageActions/MessageActions.defaults.tsx
+++ b/src/components/MessageActions/MessageActions.defaults.tsx
@@ -794,6 +794,11 @@ const DefaultMessageActionComponents = {
},
};
+/**
+ * The actions `MessageActions` renders by default. Each item's `isVisible` holds the action's own
+ * rule about the message; replace an item to change it. Capabilities, server-side features and the
+ * message's status are checked by `useBaseMessageActionSetFilter` and cannot be overridden.
+ */
export const defaultMessageActionSet: MessageActionSetItem[] = [
{
Component: DefaultMessageActionComponents.quick.DropdownToggle,
@@ -826,6 +831,7 @@ export const defaultMessageActionSet: MessageActionSetItem[] = [
},
{
Component: DefaultMessageActionComponents.dropdown.Download,
+ isVisible: ({ message }) => !!message.attachments?.some(isDownloadableAttachment),
placement: 'dropdown',
type: 'download',
},
@@ -836,6 +842,7 @@ export const defaultMessageActionSet: MessageActionSetItem[] = [
},
{
Component: DefaultMessageActionComponents.dropdown.CopyMessageText,
+ isVisible: ({ message }) => !!message.text,
placement: 'dropdown',
type: 'copyMessageText',
},
@@ -846,6 +853,8 @@ export const defaultMessageActionSet: MessageActionSetItem[] = [
},
{
Component: DefaultMessageActionComponents.dropdown.Edit,
+ // A poll message and a giphy have no content the composer can edit.
+ isVisible: ({ message }) => !message.poll_id && message.command !== 'giphy',
placement: 'dropdown',
type: 'edit',
},
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx
index 5a0b19334..d22a23f11 100644
--- a/src/components/MessageActions/MessageActions.tsx
+++ b/src/components/MessageActions/MessageActions.tsx
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import React, { type ComponentPropsWithRef, useState } from 'react';
+import type { LocalMessage } from 'stream-chat';
import {
useComponentContext,
@@ -38,7 +39,21 @@ export type DefaultMessageActionType =
| 'resendMessage'
| 'saveForLater';
+/** What an action's `isVisible` rule is checked against. */
+export type MessageActionVisibilityFilterParams = {
+ /** Whether the connected user sent the message. */
+ isMyMessage: boolean;
+ /** Whether the message is a thread reply. */
+ isThreadReply: boolean;
+ message: LocalMessage;
+};
+
type BaseMessageActionSetItem = {
+ /**
+ * Hides the action for a message, e.g. `edit` for a poll. Cannot show an action the base filter
+ * removes (capabilities, server features, message status). Shown when omitted.
+ */
+ isVisible?: (state: MessageActionVisibilityFilterParams) => boolean;
type: DefaultMessageActionType | (string & {});
};
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.tsx b/src/components/MessageActions/__tests__/MessageActions.test.tsx
index c6a00bf37..93a4e596a 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.tsx
+++ b/src/components/MessageActions/__tests__/MessageActions.test.tsx
@@ -3,7 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fromPartial } from '@total-typescript/shoehorn';
import { axe } from '../../../../axe-helper';
-import { MessageActions } from '../MessageActions';
+import { MessageActions, type MessageActionSetItem } from '../MessageActions';
import { defaultMessageActionSet } from '../MessageActions.defaults';
import { DialogManagerProvider, MessageProvider } from '../../../context';
@@ -1205,6 +1205,113 @@ describe(' ', () => {
});
});
+ describe('Action visibility rules', () => {
+ const editCapability = { channelCapabilities: { 'update-own-message': true } };
+ const pollMessage = () =>
+ generateMessage({
+ poll: fromPartial({ id: 'poll-1' }),
+ poll_id: 'poll-1',
+ text: 'please vote',
+ user: alice,
+ });
+ const editItem = () =>
+ defaultMessageActionSet.find(
+ (item) => 'type' in item && item.type === 'edit',
+ ) as MessageActionSetItem;
+ // The default set with its edit item made editable for poll messages too.
+ const withPollEditing = () =>
+ defaultMessageActionSet.map((item) =>
+ 'type' in item && item.type === 'edit'
+ ? { ...item, isVisible: () => true }
+ : item,
+ );
+ const queryEdit = () => screen.queryByRole('menuitem', { name: 'Edit Message' });
+
+ it('does not offer Edit for a poll message by default', async () => {
+ await renderMessageActions({
+ channelStateOpts: editCapability,
+ customMessageContext: { isMyMessage: () => true, message: pollMessage() },
+ });
+ await toggleOpenMessageActions();
+
+ expect(queryEdit()).not.toBeInTheDocument();
+ });
+
+ it('does not offer Edit for a poll message that failed to send', async () => {
+ // A local poll message, as the poll-only middleware builds it, has `poll_id` but no `poll`.
+ const message = generateMessage({
+ error: { status: 0 },
+ poll_id: 'poll-1',
+ status: 'failed',
+ text: '',
+ user: alice,
+ });
+ await renderMessageActions({
+ channelStateOpts: editCapability,
+ customMessageContext: { isMyMessage: () => true, message },
+ });
+ await toggleOpenMessageActions();
+
+ expect(screen.getByRole('menuitem', { name: /delete/i })).toBeInTheDocument();
+ expect(queryEdit()).not.toBeInTheDocument();
+ });
+
+ it('offers Edit for a poll message once the edit item allows it', async () => {
+ await renderMessageActions({
+ channelStateOpts: editCapability,
+ customMessageContext: { isMyMessage: () => true, message: pollMessage() },
+ messageActionsProps: { messageActionSet: withPollEditing() },
+ });
+ await toggleOpenMessageActions();
+
+ expect(queryEdit()).toBeInTheDocument();
+ });
+
+ it('passes the message, authorship and thread reply flag to isVisible', async () => {
+ const isVisible = vi.fn(() => true);
+ const message = pollMessage();
+ await renderMessageActions({
+ channelStateOpts: editCapability,
+ customMessageContext: { message },
+ messageActionsProps: {
+ messageActionSet: [
+ ...defaultMessageActionSet.filter((item) => item !== editItem()),
+ { ...editItem(), isVisible },
+ ],
+ },
+ });
+
+ expect(isVisible).toHaveBeenCalledWith({
+ isMyMessage: true,
+ isThreadReply: false,
+ message,
+ });
+ });
+
+ it('cannot show an action the user lacks the capability for', async () => {
+ await renderMessageActions({
+ customMessageContext: { isMyMessage: () => true, message: pollMessage() },
+ messageActionsProps: { messageActionSet: withPollEditing() },
+ });
+
+ expect(screen.queryByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)).toBeInTheDocument();
+ await toggleOpenMessageActions();
+ expect(queryEdit()).not.toBeInTheDocument();
+ });
+
+ it('still applies the item rules when the base filter is disabled', async () => {
+ await renderMessageActions({
+ customMessageContext: { message: generateMessage({ text: '', user: alice }) },
+ messageActionsProps: { disableBaseMessageActionSetFilter: true },
+ });
+ await toggleOpenMessageActions();
+
+ expect(
+ screen.queryByRole('menuitem', { name: /copy message/i }),
+ ).not.toBeInTheDocument();
+ });
+ });
+
describe('Custom message action sets', () => {
it('should render custom dropdown action components', async () => {
const CustomAction = () => Custom ;
diff --git a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts
index aba4d201f..4f15fecc4 100644
--- a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts
+++ b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts
@@ -10,14 +10,10 @@ import {
isNetworkSendFailure,
} from '../../Message/utils';
-import type { MessageActionSetItem } from '../MessageActions';
-import {
- isAudioAttachment,
- isFileAttachment,
- isImageAttachment,
- isVideoAttachment,
- isVoiceRecordingAttachment,
-} from 'stream-chat';
+import type {
+ MessageActionSetItem,
+ MessageActionVisibilityFilterParams,
+} from '../MessageActions';
import type { ChannelConfig } from 'stream-chat';
import { useStateStore } from '../../../store';
@@ -26,11 +22,13 @@ const userMessageRemindersStateSelector = ({ userMessageReminders }: ChannelConf
});
/**
- * Filters a message action set by the user's capabilities, whether the message is a thread reply,
- * and the message type and status (including soft-deleted and failed messages).
+ * Filters a message action set by the user's capabilities, the features enabled on the server,
+ * whether the message is a thread reply, and the message type and status (including soft-deleted
+ * and failed messages). An action these allow still shows only while its own `isVisible` holds.
*
* @param messageActionSet - Actions to filter.
- * @param disable - Returns `messageActionSet` unfiltered when true. Defaults to false.
+ * @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.
*/
@@ -56,6 +54,7 @@ export const useBaseMessageActionSetFilter = (
canReact,
canReply,
canSendMessage,
+ isMyMessage,
} = useUserRole(message);
const isMessageThreadReply = typeof message.parent_id === 'string';
const isBounced = isMessageBounced(message);
@@ -63,7 +62,17 @@ export const useBaseMessageActionSetFilter = (
const hasNetworkSendFailure = isNetworkSendFailure(message);
return useMemo(() => {
- if (disable) return messageActionSet;
+ const visibilityFilterParams: MessageActionVisibilityFilterParams = {
+ isMyMessage,
+ isThreadReply: isMessageThreadReply,
+ message,
+ };
+ const passesOwnRule = (action: MessageActionSetItem) =>
+ action.placement === 'quick-dropdown-toggle' ||
+ !action.isVisible ||
+ action.isVisible(visibilityFilterParams);
+
+ if (disable) return messageActionSet.filter(passesOwnRule);
// These messages get no actions.
if (
@@ -76,7 +85,7 @@ export const useBaseMessageActionSetFilter = (
)
return [];
- return messageActionSet.filter((action) => {
+ const allowedActions = messageActionSet.filter((action) => {
if (action.placement === 'quick-dropdown-toggle') return true;
const type = action.type;
@@ -99,16 +108,6 @@ export const useBaseMessageActionSetFilter = (
if (
type === 'resendMessage' ||
(type === 'blockUser' && !canBlockUser) ||
- (type === 'copyMessageText' && !message.text) ||
- (type === 'download' &&
- !message.attachments?.some(
- (attachment) =>
- isFileAttachment(attachment) ||
- isImageAttachment(attachment) ||
- isVideoAttachment(attachment) ||
- isAudioAttachment(attachment) ||
- isVoiceRecordingAttachment(attachment),
- )) ||
(type === 'delete' && (!canDelete || messageIsDeleted)) ||
(type === 'edit' && !canEdit) ||
(type === 'flag' && !canFlag) ||
@@ -124,6 +123,8 @@ export const useBaseMessageActionSetFilter = (
return true;
});
+
+ return allowedActions.filter(passesOwnRule);
}, [
allowRetry,
canBlockUser,
@@ -141,11 +142,8 @@ export const useBaseMessageActionSetFilter = (
isInitialMessage,
messageIsDeleted,
isMessageThreadReply,
- message.error,
- message.attachments,
- message.status,
- message.text,
- message.type,
+ isMyMessage,
+ message,
disable,
hasNetworkSendFailure,
messageActionSet,
diff --git a/src/components/MessageComposer/AttachmentPreviewList/index.ts b/src/components/MessageComposer/AttachmentPreviewList/index.ts
index e1fb8742a..d72fe34be 100644
--- a/src/components/MessageComposer/AttachmentPreviewList/index.ts
+++ b/src/components/MessageComposer/AttachmentPreviewList/index.ts
@@ -1,7 +1,7 @@
export * from './AttachmentPreviewList';
export type { AudioAttachmentPreviewProps } from './AudioAttachmentPreview';
export type { FileAttachmentPreviewProps } from './FileAttachmentPreview';
-export type { GeolocationPreviewProps } from './GeolocationPreview';
+export { GeolocationPreview, type GeolocationPreviewProps } from './GeolocationPreview';
export type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview';
export type { UploadAttachmentPreviewProps as AttachmentPreviewProps } from './types';
export {
diff --git a/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx
new file mode 100644
index 000000000..c880b5b30
--- /dev/null
+++ b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx
@@ -0,0 +1,123 @@
+/* eslint-disable sort-keys */
+import React from 'react';
+import {
+ isLocalVoiceRecordingAttachment,
+ isVoiceRecordingAttachment,
+ type LocationComposerState,
+ type MessageComposerState,
+} from 'stream-chat';
+
+import { useComponentContext } from '../../context/ComponentContext';
+import { useStateStore } from '../../store';
+import {
+ AttachmentPreviewList as DefaultAttachmentPreviewList,
+ VoiceRecordingPreviewSlot as DefaultVoiceRecordingPreviewSlot,
+} from './AttachmentPreviewList';
+import { GeolocationPreview } from './AttachmentPreviewList/GeolocationPreview';
+import { EditedMessagePreview as DefaultEditedMessagePreview } from './EditedMessagePreview';
+import { useMessageComposerController } from './hooks';
+import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList';
+import type { MessageComposerPreviewSetItem } from './MessageComposerPreviews';
+import { restorePreEditSnapshot } from './preEditSnapshot';
+import { QuotedMessagePreview as DefaultQuotedMessagePreview } from './QuotedMessagePreview';
+
+const editedMessageSelector = ({ editedMessage }: MessageComposerState) => ({
+ editedMessage,
+});
+
+const locationSelector = ({ location }: LocationComposerState) => ({ location });
+
+const DefaultMessageComposerPreviewComponents = {
+ EditedMessage() {
+ const { EditedMessagePreview = DefaultEditedMessagePreview } = useComponentContext();
+ const messageComposer = useMessageComposerController();
+ const { editedMessage } = useStateStore(messageComposer.state, editedMessageSelector);
+
+ if (!editedMessage) return null;
+
+ return (
+ {
+ restorePreEditSnapshot(messageComposer);
+ }}
+ />
+ );
+ },
+ QuotedMessage() {
+ const { QuotedMessagePreview = DefaultQuotedMessagePreview } = useComponentContext();
+ return ;
+ },
+ VoiceRecording() {
+ const { VoiceRecordingPreviewSlot = DefaultVoiceRecordingPreviewSlot } =
+ useComponentContext();
+ return ;
+ },
+ Attachments() {
+ const { AttachmentPreviewList = DefaultAttachmentPreviewList } =
+ useComponentContext();
+ return ;
+ },
+ LinkPreviews() {
+ const { LinkPreviewList = DefaultLinkPreviewList } = useComponentContext();
+ return ;
+ },
+ Location() {
+ const messageComposer = useMessageComposerController();
+ const { location } = useStateStore(
+ messageComposer.locationComposer.state,
+ locationSelector,
+ );
+ const { editedMessage } = useStateStore(messageComposer.state, editedMessageSelector);
+
+ if (!location) return null;
+
+ return (
+
+ );
+ },
+};
+
+/**
+ * The previews `MessageComposerPreviews` renders by default, in render order. Filter, reorder or
+ * extend it and pass the result as `messageComposerPreviewSet`.
+ */
+export const defaultMessageComposerPreviewSet: MessageComposerPreviewSetItem[] = [
+ {
+ Component: DefaultMessageComposerPreviewComponents.EditedMessage,
+ isVisible: ({ editedMessage }) => !!editedMessage,
+ type: 'editedMessage',
+ },
+ {
+ Component: DefaultMessageComposerPreviewComponents.QuotedMessage,
+ // The edited message preview takes the quoted message's place while editing.
+ isVisible: ({ editedMessage, quotedMessage }) => !editedMessage && !!quotedMessage,
+ type: 'quotedMessage',
+ },
+ {
+ Component: DefaultMessageComposerPreviewComponents.VoiceRecording,
+ isVisible: ({ attachments }) => attachments.some(isLocalVoiceRecordingAttachment),
+ type: 'voiceRecording',
+ },
+ {
+ Component: DefaultMessageComposerPreviewComponents.Attachments,
+ // Voice recordings render in the `voiceRecording` preview instead.
+ isVisible: ({ attachments }) =>
+ attachments.some((attachment) => !isVoiceRecordingAttachment(attachment)),
+ type: 'attachments',
+ },
+ {
+ Component: DefaultMessageComposerPreviewComponents.LinkPreviews,
+ isVisible: ({ linkPreviews }) => linkPreviews.length > 0,
+ type: 'linkPreviews',
+ },
+ {
+ Component: DefaultMessageComposerPreviewComponents.Location,
+ isVisible: ({ location }) => !!location,
+ type: 'location',
+ },
+];
diff --git a/src/components/MessageComposer/MessageComposerPreviews.tsx b/src/components/MessageComposer/MessageComposerPreviews.tsx
new file mode 100644
index 000000000..218fd3e71
--- /dev/null
+++ b/src/components/MessageComposer/MessageComposerPreviews.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+import { useMessageComposerPreviewSetFilter } from './hooks/useMessageComposerPreviewSetFilter';
+import type { MessageComposerPreviewVisibilityFilterParams } from './hooks/useMessageComposerPreviewState';
+import { defaultMessageComposerPreviewSet } from './MessageComposerPreviews.defaults';
+
+/**
+ * The preview types {@link defaultMessageComposerPreviewSet} ships, listed for autocomplete. Any
+ * string is accepted, so custom previews name themselves.
+ */
+export type DefaultMessageComposerPreviewType =
+ | 'attachments'
+ | 'editedMessage'
+ | 'linkPreviews'
+ | 'location'
+ | 'quotedMessage'
+ | 'voiceRecording';
+
+export type MessageComposerPreviewSetItem = {
+ /** Rendered without props while `isVisible` holds. Read the composer with `useMessageComposerController()`. */
+ Component: React.ComponentType;
+ /** Whether the preview shows for the current composer state. */
+ isVisible: (state: MessageComposerPreviewVisibilityFilterParams) => boolean;
+ /** Names the preview; unique within a set. */
+ type: DefaultMessageComposerPreviewType | (string & {});
+};
+
+export type MessageComposerPreviewsProps = {
+ /** Previews in render order. Defaults to {@link defaultMessageComposerPreviewSet}. */
+ messageComposerPreviewSet?: MessageComposerPreviewSetItem[];
+};
+
+/**
+ * Renders the composer's visible previews (edited or quoted message, voice recording,
+ * attachments, link previews, location) above the textarea, or nothing when none is visible.
+ */
+export const MessageComposerPreviews = ({
+ messageComposerPreviewSet = defaultMessageComposerPreviewSet,
+}: MessageComposerPreviewsProps) => {
+ const visiblePreviewSet = useMessageComposerPreviewSetFilter(messageComposerPreviewSet);
+
+ if (!visiblePreviewSet.length) return null;
+
+ return (
+
+ {visiblePreviewSet.map(({ Component, type }) => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/MessageComposer/MessageComposerUI.tsx b/src/components/MessageComposer/MessageComposerUI.tsx
index b3fefc2c4..dfad78ad7 100644
--- a/src/components/MessageComposer/MessageComposerUI.tsx
+++ b/src/components/MessageComposer/MessageComposerUI.tsx
@@ -5,20 +5,12 @@ import {
AttachmentSelector as DefaultAttachmentSelector,
SimpleAttachmentSelector,
} from './AttachmentSelector/AttachmentSelector';
-import {
- AttachmentPreviewList as DefaultAttachmentPreviewList,
- VoiceRecordingPreviewSlot as DefaultVoiceRecordingPreviewSlot,
-} from './AttachmentPreviewList';
import { AudioRecorder as DefaultAudioRecorder } from '../MediaRecorder';
-import { EditedMessagePreview as DefaultEditedMessagePreview } from './EditedMessagePreview';
-import { QuotedMessagePreview as DefaultQuotedMessagePreview } from './QuotedMessagePreview';
-import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList';
import { SendToChannelCheckbox as DefaultSendToChannelCheckbox } from './SendToChannelCheckbox';
import { TextareaComposer as DefaultTextareaComposer } from '../TextareaComposer';
import { useMessageComposerContext as useMessageComposerContext } from '../../context/MessageComposerContext';
import { useComponentContext } from '../../context/ComponentContext';
import { MessageContext } from '../../context';
-import { restorePreEditSnapshot } from './preEditSnapshot';
import { WithDragAndDropUpload } from './WithDragAndDropUpload';
import {
AdditionalMessageComposerActions as DefaultAdditionalMessageComposerActions,
@@ -26,116 +18,12 @@ import {
} from './MessageComposerActions';
import { useMessageComposerController } from './hooks';
import { useStateStore } from '../../store';
-import {
- type AttachmentManagerState,
- LinkPreviewsManager,
- type LinkPreviewsManagerState,
- type LocationComposerState,
- type MessageComposerState,
- type TextComposerState,
-} from 'stream-chat';
+import type { TextComposerState } from 'stream-chat';
import { CommandChip as DefaultCommandChip } from './CommandChip';
-import { GeolocationPreview } from './AttachmentPreviewList/GeolocationPreview';
-
-const messageComposerStateSelector = ({
- editedMessage,
- quotedMessage,
-}: MessageComposerState) => ({
- editedMessage,
- quotedMessage,
-});
-
-const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({
- attachments: state.attachments,
-});
-
-const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({
- linkPreviews: Array.from(state.previews.values()).filter(
- (preview) =>
- LinkPreviewsManager.previewIsLoaded(preview) ||
- LinkPreviewsManager.previewIsLoading(preview),
- ),
-});
-
-const locationComposerStateSelector = (state: LocationComposerState) => ({
- location: state.location,
-});
+import { MessageComposerPreviews as DefaultMessageComposerPreviews } from './MessageComposerPreviews';
const textComposerCommandSelector = ({ command }: TextComposerState) => ({ command });
-const MessageComposerPreviews = () => {
- const {
- AttachmentPreviewList = DefaultAttachmentPreviewList,
- EditedMessagePreview = DefaultEditedMessagePreview,
- LinkPreviewList = DefaultLinkPreviewList,
- QuotedMessagePreview = DefaultQuotedMessagePreview,
- VoiceRecordingPreviewSlot = DefaultVoiceRecordingPreviewSlot,
- } = useComponentContext();
-
- const messageComposer = useMessageComposerController();
- const { editedMessage, quotedMessage } = useStateStore(
- messageComposer.state,
- messageComposerStateSelector,
- );
-
- const { attachments } = useStateStore(
- messageComposer.attachmentManager.state,
- attachmentManagerStateSelector,
- );
-
- const { location } = useStateStore(
- messageComposer.locationComposer.state,
- locationComposerStateSelector,
- );
-
- const { linkPreviewsManager } = messageComposer;
- const { linkPreviews } = useStateStore(
- linkPreviewsManager.state,
- linkPreviewsManagerStateSelector,
- );
-
- if (
- !quotedMessage &&
- attachments.length === 0 &&
- linkPreviews.length === 0 &&
- !location &&
- !editedMessage
- )
- return null;
-
- // todo: pass the entity arrays from here so that the preview lists do not have to subscribe to the composer state changes too?
- return (
-
- {editedMessage ? (
-
- {
- restorePreEditSnapshot(messageComposer);
- }}
- />
-
- ) : (
-
- )}
-
-
-
- {location && (
-
- )}
-
- );
-};
-
export const MessageComposerUI = () => {
// A message context is only present while editing a message.
const { message } = useContext(MessageContext) ?? {};
@@ -151,6 +39,7 @@ export const MessageComposerUI = () => {
AttachmentSelector = message ? SimpleAttachmentSelector : DefaultAttachmentSelector,
AudioRecorder = DefaultAudioRecorder,
CommandChip = DefaultCommandChip,
+ MessageComposerPreviews = DefaultMessageComposerPreviews,
SendToChannelCheckbox = DefaultSendToChannelCheckbox,
TextareaComposer = DefaultTextareaComposer,
} = useComponentContext();
diff --git a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx
new file mode 100644
index 000000000..f8fb8b886
--- /dev/null
+++ b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx
@@ -0,0 +1,246 @@
+import React from 'react';
+import { act, cleanup, render, screen } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+import type { Attachment, Channel as ChannelType, StreamChat } from 'stream-chat';
+import { LinkPreviewStatus } from 'stream-chat';
+
+import {
+ generateFileAttachment,
+ generateLocalFileUploadAttachmentData,
+ generateMessage,
+ generatePoll,
+ generateScrapedImageAttachment,
+ generateUser,
+ initClientWithChannels,
+} from '../../../mock-builders';
+import { axe } from '../../../../axe-helper';
+import { Chat } from '../../Chat';
+import { Channel } from '../../Channel';
+import { WithComponents } from '../../../context';
+import { MessageComposer } from '../MessageComposer';
+import {
+ MessageComposerPreviews,
+ type MessageComposerPreviewSetItem,
+} from '../MessageComposerPreviews';
+import { defaultMessageComposerPreviewSet } from '../MessageComposerPreviews.defaults';
+
+const PREVIEWS_CLASS = 'str-chat__message-composer-previews';
+
+const setup = async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({ customUser: generateUser({ id: 'me' }) });
+ return { channel, client };
+};
+
+const renderPreviews = async ({
+ channel,
+ client,
+ previewSet,
+}: {
+ channel: ChannelType;
+ client: StreamChat;
+ previewSet?: MessageComposerPreviewSetItem[];
+}) => {
+ let result!: ReturnType;
+ await act(() => {
+ result = render(
+
+
+
+
+ ,
+ );
+ });
+ return result;
+};
+
+const addLinkPreview = (channel: ChannelType) => {
+ const preview = generateScrapedImageAttachment(
+ fromPartial>({ status: LinkPreviewStatus.LOADED }),
+ );
+ channel.messageComposer.linkPreviewsManager.state.next({
+ previews: new Map([[preview.og_scrape_url, preview]]),
+ });
+};
+
+const addFileAttachment = (channel: ChannelType) => {
+ channel.messageComposer.attachmentManager.upsertAttachments([
+ generateLocalFileUploadAttachmentData(
+ { uploadState: 'finished' },
+ generateFileAttachment(),
+ ),
+ ]);
+};
+
+const previewTestIds = (container: HTMLElement) =>
+ Array.from(
+ container.querySelectorAll(
+ `.${PREVIEWS_CLASS} [data-testid='quoted-message-preview'], .${PREVIEWS_CLASS} [data-testid='attachment-preview-list'], .${PREVIEWS_CLASS} [data-testid='link-preview-card'], .${PREVIEWS_CLASS} [data-testid='location-preview'], .${PREVIEWS_CLASS} [data-testid='custom-preview']`,
+ ),
+ ).map((element) => element.getAttribute('data-testid'));
+
+describe('MessageComposerPreviews', () => {
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders nothing while no preview is visible', async () => {
+ const { channel, client } = await setup();
+ const { container } = await renderPreviews({ channel, client });
+
+ expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument();
+ });
+
+ it('renders the default previews in order', async () => {
+ const { channel, client } = await setup();
+ const { container } = await renderPreviews({ channel, client });
+
+ await act(() => {
+ channel.messageComposer.setQuotedMessage(
+ generateMessage({ text: 'quoted', user: generateUser() }),
+ );
+ addFileAttachment(channel);
+ addLinkPreview(channel);
+ channel.messageComposer.locationComposer.setData({ latitude: 1, longitude: 2 });
+ });
+
+ expect(container.querySelectorAll(`.${PREVIEWS_CLASS}`)).toHaveLength(1);
+ expect(previewTestIds(container)).toEqual([
+ 'quoted-message-preview',
+ 'attachment-preview-list',
+ 'link-preview-card',
+ 'location-preview',
+ ]);
+ });
+
+ it('shows the edited message instead of the quoted message', async () => {
+ const { channel, client } = await setup();
+ const { container } = await renderPreviews({ channel, client });
+
+ await act(() => {
+ channel.messageComposer.state.partialNext({
+ editedMessage: fromPartial(
+ generateMessage({ text: 'edited', user: generateUser() }),
+ ),
+ quotedMessage: fromPartial(generateMessage({ text: 'quoted' })),
+ });
+ });
+
+ expect(screen.getByText('Edit Message')).toBeInTheDocument();
+ expect(screen.queryByText('quoted')).not.toBeInTheDocument();
+ expect(container.querySelector(`.${PREVIEWS_CLASS}`)).toBeInTheDocument();
+ });
+
+ it('renders a custom preview item when it is visible', async () => {
+ const { channel, client } = await setup();
+ const CustomPreview = () => Custom
;
+ const previewSet: MessageComposerPreviewSetItem[] = [
+ {
+ Component: CustomPreview,
+ isVisible: ({ customData }) =>
+ !!(customData as Record).showCustomPreview,
+ type: 'custom',
+ },
+ ...defaultMessageComposerPreviewSet,
+ ];
+ const { container } = await renderPreviews({ channel, client, previewSet });
+
+ expect(screen.queryByTestId('custom-preview')).not.toBeInTheDocument();
+
+ await act(() => {
+ channel.messageComposer.customDataManager.setCustomData(
+ fromPartial({ showCustomPreview: true }),
+ );
+ addLinkPreview(channel);
+ });
+
+ expect(previewTestIds(container)).toEqual(['custom-preview', 'link-preview-card']);
+ });
+
+ it('removes a default preview left out of the set', async () => {
+ const { channel, client } = await setup();
+ const { container } = await renderPreviews({
+ channel,
+ client,
+ previewSet: defaultMessageComposerPreviewSet.filter(
+ ({ type }) => type !== 'linkPreviews',
+ ),
+ });
+
+ await act(() => addLinkPreview(channel));
+
+ expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument();
+ });
+
+ it('is installed through the MessageComposerPreviews component slot', async () => {
+ const { channel, client } = await setup();
+ const CustomPreview = () => Custom
;
+ const CustomPreviews = () => (
+ true, type: 'custom' },
+ ]}
+ />
+ );
+
+ await act(() => {
+ render(
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ expect(screen.getByTestId('custom-preview')).toBeInTheDocument();
+ });
+
+ it('shows no default preview for a poll the composer holds', async () => {
+ const { channel, client } = await setup();
+ const { container } = await renderPreviews({ channel, client });
+
+ await act(() => {
+ channel.messageComposer.state.partialNext({ pollId: generatePoll().id });
+ });
+
+ expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument();
+ });
+
+ it('has no accessibility violations', async () => {
+ const { channel, client } = await setup();
+ const { container } = await renderPreviews({ channel, client });
+
+ await act(() => {
+ addFileAttachment(channel);
+ addLinkPreview(channel);
+ });
+
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it('lets a custom item show the poll the composer holds', async () => {
+ const { channel, client } = await setup();
+ const PollItem = () => Poll
;
+ const previewSet: MessageComposerPreviewSetItem[] = [
+ ...defaultMessageComposerPreviewSet,
+ {
+ Component: PollItem,
+ isVisible: ({ editedMessage, pollId }) => !editedMessage && !!pollId,
+ type: 'poll',
+ },
+ ];
+ const { container } = await renderPreviews({ channel, client, previewSet });
+
+ await act(() => {
+ channel.messageComposer.state.partialNext({ pollId: generatePoll().id });
+ });
+
+ expect(previewTestIds(container)).toEqual(['custom-preview']);
+ });
+});
diff --git a/src/components/MessageComposer/hooks/index.ts b/src/components/MessageComposer/hooks/index.ts
index e4cd3d462..49667f659 100644
--- a/src/components/MessageComposer/hooks/index.ts
+++ b/src/components/MessageComposer/hooks/index.ts
@@ -9,3 +9,5 @@ export * from './useMessageComposerController';
export * from './useMessageComposerHasSendableData';
export * from './useMessageComposerSubmitFn';
export * from './useMessageContentIsEmpty';
+export * from './useMessageComposerPreviewSetFilter';
+export * from './useMessageComposerPreviewState';
diff --git a/src/components/MessageComposer/hooks/useMessageComposerPreviewSetFilter.ts b/src/components/MessageComposer/hooks/useMessageComposerPreviewSetFilter.ts
new file mode 100644
index 000000000..7edd81516
--- /dev/null
+++ b/src/components/MessageComposer/hooks/useMessageComposerPreviewSetFilter.ts
@@ -0,0 +1,21 @@
+import { useMemo } from 'react';
+
+import { useMessageComposerPreviewState } from './useMessageComposerPreviewState';
+import type { MessageComposerPreviewSetItem } from '../MessageComposerPreviews';
+
+/**
+ * Keeps the previews whose `isVisible` holds for the current composer state.
+ *
+ * @param messageComposerPreviewSet - Previews to filter, in render order.
+ * @returns The previews to render, in the same order. Empty when none is visible.
+ */
+export const useMessageComposerPreviewSetFilter = (
+ messageComposerPreviewSet: MessageComposerPreviewSetItem[],
+) => {
+ const previewState = useMessageComposerPreviewState();
+
+ return useMemo(
+ () => messageComposerPreviewSet.filter((item) => item.isVisible(previewState)),
+ [messageComposerPreviewSet, previewState],
+ );
+};
diff --git a/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts b/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts
new file mode 100644
index 000000000..4b4806798
--- /dev/null
+++ b/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts
@@ -0,0 +1,119 @@
+import { useMemo } from 'react';
+import type {
+ AttachmentManagerState,
+ CustomDataManagerState,
+ LinkPreview,
+ LinkPreviewsManagerState,
+ LocationComposerState,
+ MessageComposerState,
+} from 'stream-chat';
+import { LinkPreviewsManager } from 'stream-chat';
+
+import { useStateStore } from '../../../store';
+import { useMessageComposerController } from './useMessageComposerController';
+
+const messageComposerStateSelector = ({
+ editedMessage,
+ pollId,
+ quotedMessage,
+}: MessageComposerState) => ({
+ editedMessage,
+ pollId,
+ quotedMessage,
+});
+
+const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({
+ attachments: state.attachments,
+});
+
+const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({
+ previews: state.previews,
+});
+
+const locationComposerStateSelector = (state: LocationComposerState) => ({
+ location: state.location,
+});
+
+const customDataManagerStateSelector = (state: CustomDataManagerState) => ({
+ customData: state.custom,
+});
+
+/**
+ * The composer state that decides which previews show. Each item of a
+ * `messageComposerPreviewSet` reads it in `isVisible`.
+ */
+export type MessageComposerPreviewVisibilityFilterParams = {
+ attachments: AttachmentManagerState['attachments'];
+ /** The composer's custom data (`messageComposer.customDataManager`). */
+ customData: CustomDataManagerState['custom'];
+ editedMessage: MessageComposerState['editedMessage'];
+ /** Link previews that are loading or loaded. */
+ linkPreviews: LinkPreview[];
+ location: LocationComposerState['location'];
+ /**
+ * A poll created with `messageComposer.createPoll()` and not sent yet. No default preview shows
+ * it; an app that attaches polls to the message adds an item for it.
+ */
+ pollId: MessageComposerState['pollId'];
+ quotedMessage: MessageComposerState['quotedMessage'];
+};
+
+/**
+ * Subscribes to the composer state that decides which previews show, for the composer
+ * `useMessageComposerController()` resolves.
+ */
+export const useMessageComposerPreviewState =
+ (): MessageComposerPreviewVisibilityFilterParams => {
+ const messageComposer = useMessageComposerController();
+ const { editedMessage, pollId, quotedMessage } = useStateStore(
+ messageComposer.state,
+ messageComposerStateSelector,
+ );
+ const { attachments } = useStateStore(
+ messageComposer.attachmentManager.state,
+ attachmentManagerStateSelector,
+ );
+ const { previews } = useStateStore(
+ messageComposer.linkPreviewsManager.state,
+ linkPreviewsManagerStateSelector,
+ );
+ const { location } = useStateStore(
+ messageComposer.locationComposer.state,
+ locationComposerStateSelector,
+ );
+ const { customData } = useStateStore(
+ messageComposer.customDataManager.state,
+ customDataManagerStateSelector,
+ );
+
+ const linkPreviews = useMemo(
+ () =>
+ Array.from(previews.values()).filter(
+ (preview) =>
+ LinkPreviewsManager.previewIsLoaded(preview) ||
+ LinkPreviewsManager.previewIsLoading(preview),
+ ),
+ [previews],
+ );
+
+ return useMemo(
+ () => ({
+ attachments,
+ customData,
+ editedMessage,
+ linkPreviews,
+ location,
+ pollId,
+ quotedMessage,
+ }),
+ [
+ attachments,
+ customData,
+ editedMessage,
+ linkPreviews,
+ location,
+ pollId,
+ quotedMessage,
+ ],
+ );
+ };
diff --git a/src/components/MessageComposer/index.ts b/src/components/MessageComposer/index.ts
index 5d19f518c..09f0b2ba4 100644
--- a/src/components/MessageComposer/index.ts
+++ b/src/components/MessageComposer/index.ts
@@ -1,6 +1,7 @@
export * from './AttachmentSelector';
export {
AttachmentPreviewList,
+ GeolocationPreview,
UnsupportedAttachmentPreview,
VoiceRecordingPreviewSlot,
} from './AttachmentPreviewList';
@@ -16,11 +17,14 @@ export type {
} from './AttachmentPreviewList';
export * from './CommandChip';
export * from './CooldownTimer';
+export * from './EditedMessagePreview';
export * from './hooks';
export * from './icons';
export * from './LinkPreviewList';
export * from './MessageComposer';
export * from './MessageComposerActions';
+export * from './MessageComposerPreviews';
+export * from './MessageComposerPreviews.defaults';
export * from './MessageComposerUI';
export * from './QuotedMessagePreview';
export * from './RemoveAttachmentPreviewButton';
diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx
index e0d2e7bed..eba871933 100644
--- a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx
+++ b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx
@@ -3,12 +3,12 @@ import type { PollComposerState } from 'stream-chat';
import { MultipleAnswersField } from './MultipleAnswersField';
import { NameField } from './NameField';
import { OptionFieldSet } from './OptionFieldSet';
-import { PollCreationDialogControls } from './PollCreationDialogControls';
+import { PollCreationDialogControls as DefaultPollCreationDialogControls } from './PollCreationDialogControls';
import { Prompt } from '../../Dialog';
import { SwitchField } from '../../Form/SwitchField';
import { useInteractionAnnouncements } from '../../Accessibility';
import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController';
-import { useTranslationContext } from '../../../context';
+import { useComponentContext, useTranslationContext } from '../../../context';
import { useStateStore } from '../../../store';
export type PollCreationDialogProps = {
@@ -22,6 +22,8 @@ const pollComposerStateSelector = (state: PollComposerState) => ({
});
export const PollCreationDialog = ({ close }: PollCreationDialogProps) => {
+ const { PollCreationDialogControls = DefaultPollCreationDialogControls } =
+ useComponentContext();
const { t } = useTranslationContext();
const { announceInteraction } = useInteractionAnnouncements();
const { pollComposer } = useMessageComposerController();
diff --git a/src/components/Poll/PollCreationDialog/index.ts b/src/components/Poll/PollCreationDialog/index.ts
index e4eb0a0a9..87a02d8af 100644
--- a/src/components/Poll/PollCreationDialog/index.ts
+++ b/src/components/Poll/PollCreationDialog/index.ts
@@ -1 +1,2 @@
export * from './PollCreationDialog';
+export * from './PollCreationDialogControls';
diff --git a/src/components/Poll/__tests__/PollCreationDialog.test.tsx b/src/components/Poll/__tests__/PollCreationDialog.test.tsx
index 8a96e468b..30c28bf37 100644
--- a/src/components/Poll/__tests__/PollCreationDialog.test.tsx
+++ b/src/components/Poll/__tests__/PollCreationDialog.test.tsx
@@ -12,9 +12,13 @@ import {
} from '@testing-library/react';
import { Chat } from '../../Chat';
import { Channel } from '../../Channel';
-import { MessageComposerContextProvider } from '../../../context';
+import { MessageComposerContextProvider, WithComponents } from '../../../context';
+import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController';
import { generateUser, initClientWithChannels } from '../../../mock-builders';
-import { PollCreationDialog } from '../PollCreationDialog';
+import {
+ PollCreationDialog,
+ type PollCreationDialogControlsProps,
+} from '../PollCreationDialog';
import { fromPartial } from '@total-typescript/shoehorn';
import type { Channel as ChannelType, StreamChat } from 'stream-chat';
import type { MessageComposerContextValue } from '../../../context';
@@ -969,4 +973,61 @@ describe('PollCreationDialog', () => {
});
expect(close).toHaveBeenCalledTimes(1);
});
+
+ it('renders the PollCreationDialogControls slot in place of the default footer', async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels();
+ vi.spyOn(client, 'createPoll').mockResolvedValue(
+ fromPartial({ poll: { id: 'attached-poll', options: [] } }),
+ );
+ const sendMessageSpy = spyOnSendMessage(channel);
+ const AttachPollControls = ({ close }: PollCreationDialogControlsProps) => {
+ const messageComposer = useMessageComposerController();
+ return (
+ {
+ await messageComposer.createPoll();
+ close();
+ }}
+ type='button'
+ >
+ Attach poll
+
+ );
+ };
+
+ await act(() => {
+ render(
+
+
+
+ ({})}
+ >
+
+
+
+
+ ,
+ );
+ });
+
+ expect(screen.queryByRole('button', { name: /send poll/i })).not.toBeInTheDocument();
+
+ await act(() => {
+ fireEvent.change(getNameInput(), { target: { value: 'Question' } });
+ });
+ await act(() => {
+ fireEvent.change(getOptionInput(), { target: { value: 'Option' } });
+ });
+ await act(() => {
+ fireEvent.click(screen.getByRole('button', { name: 'Attach poll' }));
+ });
+
+ await waitFor(() => expect(channel.messageComposer.pollId).toBe('attached-poll'));
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index c95b1d318..be0f17111 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -38,6 +38,7 @@ import {
type NotificationListProps,
type NotificationProps,
type PinIndicatorProps,
+ type PollCreationDialogControlsProps,
type PollCreationDialogProps,
type PollOptionSelectorProps,
type QuotedMessagePreviewProps,
@@ -190,6 +191,11 @@ export type ComponentContextValue = {
* overriding one icon leaves the rest intact. Read with `useComponentContextIcons`.
*/
icons?: IconSlots;
+ /**
+ * Previews above the composer's textarea. The default renders `defaultMessageComposerPreviewSet`;
+ * render `MessageComposerPreviews` with your own `messageComposerPreviewSet` to change it.
+ */
+ MessageComposerPreviews?: React.ComponentType;
/** How the message composer renders. */
MessageComposerUI?: React.ComponentType;
/** Link previews in the message composer. */
@@ -198,6 +204,11 @@ export type ComponentContextValue = {
LoadingIndicator?: React.ComponentType;
/** Determinate progress from 0 to 100. */
ProgressIndicator?: React.ComponentType;
+ /**
+ * What a message bubble holds. The default renders `defaultMessageBubbleContentSet`; render
+ * `MessageBubbleContent` with your own `messageBubbleContentSet` to change it.
+ */
+ MessageBubbleContent?: React.ComponentType;
/** Renders a message in the standard `MessageList`. */
MessageUI?: React.ComponentType;
/** Message actions popup, rendered without props. */
@@ -250,6 +261,12 @@ export type ComponentContextValue = {
PollContent?: React.ComponentType;
/** Contents of the poll creation dialog. */
PollCreationDialog?: React.ComponentType;
+ /**
+ * Footer of the poll creation dialog. The default cancels, or creates the poll and sends it at
+ * once. Calling `messageComposer.createPoll()` without `send()` instead leaves the poll in the
+ * composer (`state.pollId`) to go out with the next message.
+ */
+ PollCreationDialogControls?: React.ComponentType;
/** Poll header in a message. */
PollHeader?: React.ComponentType;
/** Poll option selector. */
diff --git a/yarn.lock b/yarn.lock
index d2028223e..46ae15ae1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2197,7 +2197,7 @@ __metadata:
react: "npm:^19.2.6"
react-dom: "npm:^19.2.6"
sass: "npm:^1.100.0"
- stream-chat: "npm:10.0.0-rc.28"
+ stream-chat: "npm:10.0.0-rc.29"
stream-chat-react: "workspace:^"
typescript: "npm:^6.0.3"
languageName: unknown
@@ -2219,7 +2219,7 @@ __metadata:
react: "npm:^19.2.6"
react-dom: "npm:^19.2.6"
react-leaflet: "npm:^5.0.0"
- stream-chat: "npm:10.0.0-rc.28"
+ stream-chat: "npm:10.0.0-rc.29"
stream-chat-react: "workspace:^"
typescript: "npm:^6.0.3"
vite: "npm:^8.1.3"
@@ -2237,7 +2237,7 @@ __metadata:
emoji-mart: "npm:^5.6.0"
react: "npm:^19.2.6"
react-dom: "npm:^19.2.6"
- stream-chat: "npm:10.0.0-rc.28"
+ stream-chat: "npm:10.0.0-rc.29"
stream-chat-react: "workspace:^"
typescript: "npm:^6.0.3"
vite: "npm:^8.1.3"
@@ -2265,7 +2265,7 @@ __metadata:
react: "npm:^19.2.6"
react-dom: "npm:^19.2.6"
sass: "npm:^1.100.0"
- stream-chat: "npm:10.0.0-rc.28"
+ stream-chat: "npm:10.0.0-rc.29"
stream-chat-react: "workspace:^"
typescript: "npm:^6.0.3"
vite: "npm:^8.1.3"
@@ -10340,7 +10340,7 @@ __metadata:
remark-parse: "npm:^11.0.0"
sass: "npm:^1.100.0"
semantic-release: "npm:^25.0.3"
- stream-chat: "npm:10.0.0-rc.28"
+ stream-chat: "npm:10.0.0-rc.29"
typescript: "npm:^6.0.3"
typescript-eslint: "npm:^8.59.4"
unified: "npm:^11.0.5"
@@ -10358,7 +10358,7 @@ __metadata:
modern-normalize: ^3.0.1
react: ^19.0.0 || ^18.0.0 || ^17.0.0
react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0
- stream-chat: ^10.0.0-rc.28
+ stream-chat: ^10.0.0-rc.29
dependenciesMeta:
"@parcel/watcher":
built: true
@@ -10382,9 +10382,9 @@ __metadata:
languageName: unknown
linkType: soft
-"stream-chat@npm:10.0.0-rc.28":
- version: 10.0.0-rc.28
- resolution: "stream-chat@npm:10.0.0-rc.28"
+"stream-chat@npm:10.0.0-rc.29":
+ version: 10.0.0-rc.29
+ resolution: "stream-chat@npm:10.0.0-rc.29"
dependencies:
"@stream-io/logger": "npm:^2.1.0"
"@stream-io/state-store": "npm:^1.2.0"
@@ -10395,7 +10395,7 @@ __metadata:
built: true
husky:
built: true
- checksum: 10c0/0132ae268d1b228eeb8b70a08b03eff77e955f8a2215fcf7804fa4df8dd7c032adc3a356d934cbf883fd4835f823612ec9d53ccf3948c494023ae5d636341f01
+ checksum: 10c0/aef5fcb8c3ee4c93f1fb4bcd5d939dc6e9263ac14178dce632671bdce7993fb8e766076221975756cc7956bcdd797278c2bff7ead4fcc7046dc449130de10f13
languageName: node
linkType: hard