From f9b45aba7fb0023ebbfc9b2cb7b8dc3dd8fcf3ed Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 16:32:32 +0100 Subject: [PATCH 1/3] docs(examples): build the poll recipe on the composer preview set The poll card is now a `poll` item in the MessageComposerPreviews set instead of a wrapper around MessageComposerUI, and the Create poll dialog keeps its form: only its footer is replaced, through the PollCreationDialogControls slot. installPollWithText removes the poll-only middleware by its exported id and leaves retainCompositionOnSubmit at its default, which empties the composer once that middleware is gone. --- .../AddPollDialog.css | 7 -- .../AddPollDialog.tsx | 96 ------------------- .../AttachPollControls.tsx | 49 ++++++++++ .../ComposerPollPreview.css | 5 - .../ComposerPollPreview.tsx | 50 +++++----- .../WithPollWithText.tsx | 8 +- .../installPollWithText.ts | 30 +++--- .../removePendingPoll.ts | 6 +- .../poll-with-text-and-attachments/shots.json | 2 +- 9 files changed, 96 insertions(+), 157 deletions(-) delete mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.css delete mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.tsx create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.css b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.css deleted file mode 100644 index 55b74c1c8..000000000 --- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.css +++ /dev/null @@ -1,7 +0,0 @@ -@layer stream-overrides { - .cookbook-add-poll__form { - display: flex; - flex-direction: column; - gap: var(--str-chat__spacing-sm); - } -} 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 deleted file mode 100644 index d4783c895..000000000 --- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import type { PollComposerState } from 'stream-chat'; -import type { PollCreationDialogProps } from 'stream-chat-react'; -import { - Prompt, - TextInput, - useCanCreatePoll, - useMessageComposerController, - useStateStore, -} from 'stream-chat-react'; -import './AddPollDialog.css'; - -const pollFieldsSelector = ({ data }: PollComposerState) => ({ - name: data.name, - options: data.options, -}); - -/** - * Creates the poll and adds it to the composer, without sending anything. The message goes out - * when the user sends it from the composer. `AttachmentSelector` resets the form each time it opens - * the dialog. - */ -export const AddPollDialog = ({ close }: PollCreationDialogProps) => { - const messageComposer = useMessageComposerController(); - const { pollComposer } = messageComposer; - const { name, options } = useStateStore(pollComposer.state, pollFieldsSelector); - const canCreatePoll = useCanCreatePoll(); - - const addPoll = async () => { - close(); - try { - // 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. - } - }; - - return ( - - - -
{ - event.preventDefault(); - if (canCreatePoll) void addPoll(); - }} - > - pollComposer.handleFieldBlur('name')} - onChange={(event) => - pollComposer.updateFields({ data: { name: event.target.value } }) - } - placeholder='Ask a question' - value={name} - /> - {/* The poll composer adds an empty option after the last filled one, and drops an - emptied option that isn't the last. */} - {options.map((option, index) => ( - - pollComposer.updateFields({ - data: { options: { index, text: event.target.value } }, - }) - } - placeholder='Add an option' - value={option.text} - /> - ))} - -
- - - - Cancel - - void addPoll()} - type='button' - > - Add to message - - - -
- ); -}; diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx new file mode 100644 index 000000000..8e47ca37a --- /dev/null +++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx @@ -0,0 +1,49 @@ +import { flushSync } from 'react-dom'; +import type { PollCreationDialogControlsProps } from 'stream-chat-react'; +import { + Prompt, + useCanCreatePoll, + useMessageComposerContext, + useMessageComposerController, +} from 'stream-chat-react'; + +/** + * The Create poll dialog's footer. "Add to message" creates the poll and adds it to the composer, + * without sending anything. The message goes out when the user sends it from the composer. + */ +export const AttachPollControls = ({ close }: PollCreationDialogControlsProps) => { + const { textareaRef } = useMessageComposerContext(); + const messageComposer = useMessageComposerController(); + const canCreatePoll = useCanCreatePoll(); + + const addPoll = async () => { + // Closes the dialog and focuses the composer in one step, so focus doesn't pass through the + // attachment button that opened the dialog. + flushSync(() => close()); + textareaRef?.current?.focus(); + try { + // Creates the poll on the server and sets `messageComposer.state.pollId`, so a new poll + // replaces one already waiting in the composer. + await messageComposer.createPoll(); + } catch { + // `createPoll()` already reported the failure as a notification. + } + }; + + return ( + + + + Cancel + + void addPoll()} + type='submit' + > + Add to message + + + + ); +}; diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.css b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.css index a80345933..ca62bf2d9 100644 --- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.css +++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.css @@ -1,14 +1,9 @@ @layer stream-overrides { - .cookbook-poll-composer { - width: 100%; - } - .cookbook-poll-preview { position: relative; display: flex; align-items: flex-start; gap: var(--str-chat__spacing-sm); - margin: var(--str-chat__spacing-sm) var(--str-chat__spacing-md) 0; padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-md); border: 1px solid var(--str-chat__border-core-default); border-radius: var(--str-chat__radius-lg); 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 a90adc3c1..daad14b96 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 @@ -1,7 +1,9 @@ import { useEffect, useState } from 'react'; import type { MessageComposerState, Poll, PollState } from 'stream-chat'; +import type { MessageComposerPreviewSetItem } from 'stream-chat-react'; import { - MessageComposerUI, + defaultMessageComposerPreviewSet, + MessageComposerPreviews, RemoveAttachmentPreviewButton, useChatContext, useMessageComposerController, @@ -10,10 +12,7 @@ import { import { removePendingPoll } from './removePendingPoll'; import './ComposerPollPreview.css'; -const composerPollSelector = ({ editedMessage, pollId }: MessageComposerState) => ({ - editedMessage, - pollId, -}); +const composerPollSelector = ({ pollId }: MessageComposerState) => ({ pollId }); const pollPreviewSelector = ({ name, options }: PollState) => ({ name, options }); /** @@ -77,32 +76,39 @@ const PollPreviewCard = ({ onRemove, poll }: PollPreviewCardProps) => { }; /** The poll waiting in the composer, while `messageComposer.state.pollId` is set. */ -export const ComposerPollPreview = () => { +const ComposerPollPreview = () => { const messageComposer = useMessageComposerController(); - const { editedMessage, pollId } = useStateStore( - messageComposer.state, - composerPollSelector, - ); + const { pollId } = useStateStore(messageComposer.state, composerPollSelector); const poll = usePendingPoll(pollId); if (!pollId) return null; return ( removePendingPoll(messageComposer)} + // Takes the poll out of the composer, without deleting it. + onRemove={() => removePendingPoll(messageComposer)} poll={poll} /> ); }; -/** - * The default composer with the poll preview above it. The composer's own preview area renders - * only for a quoted message, attachments, link previews, a location or an edit, never for a poll - * alone, so the preview sits outside it. - */ -export const MessageComposerUIWithPoll = () => ( -
- - -
+const pollPreviewItem: MessageComposerPreviewSetItem = { + Component: ComposerPollPreview, + // An edited message keeps its poll, so the preview shows only for a new message. + isVisible: ({ editedMessage, pollId }) => !editedMessage && !!pollId, + type: 'poll', +}; + +// The poll comes right after a quoted message, before the attachments, as in the sent message. +const quotedMessageIndex = defaultMessageComposerPreviewSet.findIndex( + ({ type }) => type === 'quotedMessage', +); +const previewSetWithPoll = [ + ...defaultMessageComposerPreviewSet.slice(0, quotedMessageIndex + 1), + pollPreviewItem, + ...defaultMessageComposerPreviewSet.slice(quotedMessageIndex + 1), +]; + +/** The composer's previews, with the poll among them. */ +export const MessageComposerPreviewsWithPoll = () => ( + ); diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/WithPollWithText.tsx b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/WithPollWithText.tsx index e16a53ffe..23ec24297 100644 --- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/WithPollWithText.tsx +++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/WithPollWithText.tsx @@ -1,14 +1,14 @@ import type { PropsWithChildren } from 'react'; import { useEffect } from 'react'; import { useChatContext, WithComponents } from 'stream-chat-react'; -import { AddPollDialog } from './AddPollDialog'; -import { MessageComposerUIWithPoll } from './ComposerPollPreview'; +import { AttachPollControls } from './AttachPollControls'; +import { MessageComposerPreviewsWithPoll } from './ComposerPollPreview'; import { installPollWithText } from './installPollWithText'; // Declared once, so WithComponents gets the same object on every render. const overrides = { - MessageComposerUI: MessageComposerUIWithPoll, - PollCreationDialog: AddPollDialog, + MessageComposerPreviews: MessageComposerPreviewsWithPoll, + PollCreationDialogControls: AttachPollControls, }; const PollWithTextSetup = () => { diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/installPollWithText.ts b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/installPollWithText.ts index 89506374b..f422fd989 100644 --- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/installPollWithText.ts +++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/installPollWithText.ts @@ -1,6 +1,9 @@ import type { StreamChat } from 'stream-chat'; +import { + createPollOnlyCompositionMiddleware, + POLL_ONLY_COMPOSITION_MIDDLEWARE_ID, +} from 'stream-chat'; -const POLL_ONLY_MIDDLEWARE_ID = 'stream-io/message-composer-middleware/poll-only'; const USER_DATA_INJECTION_MIDDLEWARE_ID = 'stream-io/message-composer-middleware/user-data-injection'; @@ -8,26 +11,17 @@ const USER_DATA_INJECTION_MIDDLEWARE_ID = export const installPollWithText = (client: StreamChat) => { client.config.setSetupFunction('messageComposer', ({ composer }) => { const executor = composer.compositionMiddlewareExecutor; - const pollOnly = executor.installedMiddleware.find( - ({ id }) => id === POLL_ONLY_MIDDLEWARE_ID, - ); - const { retainCompositionOnSubmit } = composer.config; - executor.remove(POLL_ONLY_MIDDLEWARE_ID); - // The SDK keeps the composer's content after sending a poll, because its poll message leaves - // the rest out. Here everything goes out with the poll, so the composer empties as for any - // message. - composer.updateConfig({ retainCompositionOnSubmit: () => false }); + // Without the poll-only step, the message keeps its text and attachments, and the composer + // empties after the send, as for any message. + executor.remove(POLL_ONLY_COMPOSITION_MIDDLEWARE_ID); return () => { - if (pollOnly) { - executor.insert({ - middleware: [pollOnly], - position: { after: USER_DATA_INJECTION_MIDDLEWARE_ID }, - unique: true, - }); - } - composer.updateConfig({ retainCompositionOnSubmit }); + executor.insert({ + middleware: [createPollOnlyCompositionMiddleware(composer)], + position: { after: USER_DATA_INJECTION_MIDDLEWARE_ID }, + unique: true, + }); }; }); 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 ef9cee584..2f0cc6286 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,10 +1,8 @@ import type { MessageComposer } from 'stream-chat'; /** - * 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. + * Takes the poll waiting in a new message out of the composer, without deleting it. Call it when + * the user removes the poll. A poll of an edited message belongs to that message and stays. */ export const removePendingPoll = (composer: MessageComposer) => { const { editedMessage, pollId } = composer; diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/shots.json b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/shots.json index 076bb4274..1223ea358 100644 --- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/shots.json +++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/shots.json @@ -6,7 +6,7 @@ "poll": "composer" }, "waitFor": ".cookbook-poll-preview", - "clip": ".cookbook-poll-composer", + "clip": ".str-chat__message-composer", "padding": 0 }, { From 6807ee32e7b3aac9baaa402e412780286c72f38a Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 3 Nov 2026 12:37:41 +0100 Subject: [PATCH 2/3] docs(examples): close the poll dialog once the poll is attached A message sent from the composer while the poll was still being created went out without it. The dialog now closes after createPoll() settles, and Add to message is disabled meanwhile. The migration guide's example gets the same order. --- ai-docs/ai-migration-v14-v15.md | 4 +++- .../AttachPollControls.tsx | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index f51dfc98b..2fc508b55 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2414,12 +2414,14 @@ const AttachPollControls = ({ close }: PollCreationDialogControlsProps) => { { - 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. + } finally { + // Closed once the poll is attached, so a message sent meanwhile cannot miss it. + close(); } }} type='submit' diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx index 8e47ca37a..c78f3b76a 100644 --- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx +++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { flushSync } from 'react-dom'; import type { PollCreationDialogControlsProps } from 'stream-chat-react'; import { @@ -16,17 +17,23 @@ export const AttachPollControls = ({ close }: PollCreationDialogControlsProps) = const messageComposer = useMessageComposerController(); const canCreatePoll = useCanCreatePoll(); + const [isCreating, setIsCreating] = useState(false); + const addPoll = async () => { - // Closes the dialog and focuses the composer in one step, so focus doesn't pass through the - // attachment button that opened the dialog. - flushSync(() => close()); - textareaRef?.current?.focus(); + setIsCreating(true); try { // Creates the poll on the server and sets `messageComposer.state.pollId`, so a new poll // replaces one already waiting in the composer. await messageComposer.createPoll(); } catch { // `createPoll()` already reported the failure as a notification. + } finally { + setIsCreating(false); + // Closed only once the poll is attached: a message sent from the composer while the poll + // is still being created would go out without it. Closing and focusing the composer in one + // step keeps focus from passing through the attachment button that opened the dialog. + flushSync(() => close()); + textareaRef?.current?.focus(); } }; @@ -37,7 +44,7 @@ export const AttachPollControls = ({ close }: PollCreationDialogControlsProps) = Cancel void addPoll()} type='submit' > From cd6c2c6a38d83026b72fd8c4ebfda8bee8ea591b Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 3 Nov 2026 13:17:03 +0100 Subject: [PATCH 3/3] docs(examples): disable the poll dialog's buttons while the poll is created Cancel is disabled while the poll is being created, since the poll would still be attached after it. The migration guide's example gets the same guard on both buttons. --- ai-docs/ai-migration-v14-v15.md | 12 +++++++++++- .../AttachPollControls.tsx | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 2fc508b55..4018b1c4f 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2396,6 +2396,7 @@ The default poll creation dialog sends the poll as soon as it is created. To hav 2. Replace the dialog's footer through the new `PollCreationDialogControls` slot (`PollCreationDialogControls` and `PollCreationDialogControlsProps` are exported) and call `createPoll()` without `send()`: ```tsx +import { useState } from 'react'; import { type PollCreationDialogControlsProps, Prompt, @@ -2405,21 +2406,30 @@ import { const AttachPollControls = ({ close }: PollCreationDialogControlsProps) => { const messageComposer = useMessageComposerController(); + // Blocks a second request, and a Cancel that the pending poll would ignore. + const [isCreating, setIsCreating] = useState(false); return ( - + Cancel { + setIsCreating(true); 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. } finally { + setIsCreating(false); // Closed once the poll is attached, so a message sent meanwhile cannot miss it. close(); } diff --git a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx index c78f3b76a..95f5ea2e4 100644 --- a/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx +++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx @@ -40,7 +40,13 @@ export const AttachPollControls = ({ close }: PollCreationDialogControlsProps) = return ( - + {/* Disabled while the poll is being created: the poll would still be attached after a + Cancel. */} + Cancel