diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index f51dfc98b..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,32 @@ 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 { - close(); + 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(); } }} type='submit' 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..95f5ea2e4 --- /dev/null +++ b/examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AttachPollControls.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +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 [isCreating, setIsCreating] = useState(false); + + const addPoll = async () => { + 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(); + } + }; + + return ( + + + {/* Disabled while the poll is being created: the poll would still be attached after a + Cancel. */} + + 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 }, {