Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions ai-docs/ai-migration-v14-v15.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<Prompt.Footer>
<Prompt.FooterControls>
<Prompt.FooterControlsButtonSecondary onClick={close} type='button'>
<Prompt.FooterControlsButtonSecondary
disabled={isCreating}
onClick={close}
type='button'
>
Cancel
</Prompt.FooterControlsButtonSecondary>
<Prompt.FooterControlsButtonPrimary
disabled={isCreating}
onClick={async () => {
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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}}
type='submit'
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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);
Comment thread
MartinCupela marked this conversation as resolved.
Resolved
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 (
<Prompt.Footer>
<Prompt.FooterControls>
{/* Disabled while the poll is being created: the poll would still be attached after a
Cancel. */}
<Prompt.FooterControlsButtonSecondary
disabled={isCreating}
onClick={close}
type='button'
>
Cancel
</Prompt.FooterControlsButtonSecondary>
<Prompt.FooterControlsButtonPrimary
disabled={!canCreatePoll || isCreating}
onClick={() => void addPoll()}
type='submit'
>
Add to message
</Prompt.FooterControlsButtonPrimary>
</Prompt.FooterControls>
</Prompt.Footer>
);
};
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 });

/**
Expand Down Expand Up @@ -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 (
<PollPreviewCard
// Takes the poll out of the composer; the poll stays on the server.
onRemove={editedMessage ? undefined : () => 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 = () => (
<div className='cookbook-poll-composer'>
<ComposerPollPreview />
<MessageComposerUI />
</div>
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 = () => (
<MessageComposerPreviews messageComposerPreviewSet={previewSetWithPoll} />
);
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,27 @@
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';

/** Sends a poll with the text and attachments of its message. Returns a function that stops it. */
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,
});
};
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"poll": "composer"
},
"waitFor": ".cookbook-poll-preview",
"clip": ".cookbook-poll-composer",
"clip": ".str-chat__message-composer",
"padding": 0
},
{
Expand Down
Loading