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
4 changes: 2 additions & 2 deletions ai-docs/v10-breaking-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2645,7 +2645,7 @@ Poll data types and `PollResponse` renaming: [poll types](#poll-types). State ti
**Kind:** removed · **Impact:** medium · **PR:** #1880, #1881

- **v9:** install `createSendWithPendingUploadsAttachmentsMiddleware(composer)` via `compositionMiddlewareExecutor.replace([...])`; it carried `MessageCompositionMiddleware.allowsPendingUploads: true`; `composer.allowsPendingUploads` scanned `compositionMiddlewareExecutor.installedMiddleware` (narrowed override on `MessageComposerMiddlewareExecutor`). The caller had to await uploads and patch URLs into the payload before sending.
- **v10:** the export, the `allowsPendingUploads` middleware field and the `installedMiddleware` narrowing are gone. The switch is `attachments.pendingUploadsEnabled` (default `false`), read per composition by the stock `createAttachmentsCompositionMiddleware`. `composer.allowsPendingUploads` returns `attachmentManager.config.pendingUploadsEnabled`. Settling uploads moved into `channel|thread.messageOperations.send` (`settlePendingAttachmentUploads`). In this mode `failed` uploads count for `hasSendableData` and ride along on `localMessage.attachments` (retried at send); only `blocked` stays behind. The "keep in composer" rule is `config.retainCompositionOnSubmit(composer)` (default `!!composer.pollId`, same as v9's hardcoded check).
- **v10:** the export, the `allowsPendingUploads` middleware field and the `installedMiddleware` narrowing are gone. The switch is `attachments.pendingUploadsEnabled` (default `false`), read per composition by the stock `createAttachmentsCompositionMiddleware`. `composer.allowsPendingUploads` returns `attachmentManager.config.pendingUploadsEnabled`. Settling uploads moved into `channel|thread.messageOperations.send` (`settlePendingAttachmentUploads`). In this mode `failed` uploads count for `hasSendableData` and ride along on `localMessage.attachments` (retried at send); only `blocked` stays behind. The "keep in composer" rule is `config.retainCompositionOnSubmit(composer)` (default `!!composer.pollId` while the poll-only composition middleware is installed, same as v9's hardcoded check). To send a poll together with the message's text and attachments, remove `POLL_ONLY_COMPOSITION_MIDDLEWARE_ID` from `composer.compositionMiddlewareExecutor`; the default then stops retaining.
- **Migrate:** delete the middleware install; `client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled: true } } })` (or `composer.updateConfig(...)`). Drop any own await/patch-URLs step and send through `composer.send()` or `messageOperations.send`, otherwise uploads are handled twice. A custom middleware with `allowsPendingUploads: true` now does nothing.
- **Source:** `src/messageComposer/middleware/messageComposer/attachments.ts:54,62,124`, `src/messageComposer/messageComposer.ts:478,524`, `src/messageComposer/configuration/types.ts:109`, `src/messageOperations/createMessageOperations.ts:43` (v10); `src/messageComposer/middleware/messageComposer/attachments.ts:43,108`, `src/messageComposer/middleware/messageComposer/types.ts:55`, `src/messageComposer/messageComposer.ts:448`, `src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts:64` (v9)

Expand Down Expand Up @@ -2700,7 +2700,7 @@ Poll data types and `PollResponse` renaming: [poll types](#poll-types). State ti
**Kind:** type-shape · **Impact:** low · **PR:** #1831, #1881

- **v9:** `MessageComposerConfig { attachments, commands, drafts, linkPreviews, location, text }`; `AttachmentManagerConfig` without `enabled` / `customCdn` / `pendingUploadsEnabled`; `LocationComposerConfig { enabled, getDeviceId }` with a hardcoded 60 s live-location minimum.
- **v10:** required `MessageComposerConfig.polls: PollComposerConfig` (`{ enabled: true }`), `MessageComposerConfig.retainCompositionOnSubmit: (composer) => boolean` (default `!!composer.pollId`), `AttachmentManagerConfig.enabled` (`true`), `.customCdn` (`false`), `.pendingUploadsEnabled` (`false`), `LocationComposerConfig.minShareDurationMs` (`60000`, replaces the hardcoded constant; a shorter `durationMs` makes `validLocation` null).
- **v10:** required `MessageComposerConfig.polls: PollComposerConfig` (`{ enabled: true }`), `MessageComposerConfig.retainCompositionOnSubmit: (composer) => boolean` (default `!!composer.pollId` while `POLL_ONLY_COMPOSITION_MIDDLEWARE_ID` is installed), `AttachmentManagerConfig.enabled` (`true`), `.customCdn` (`false`), `.pendingUploadsEnabled` (`false`), `LocationComposerConfig.minShareDurationMs` (`60000`, replaces the hardcoded constant; a shorter `durationMs` makes `validLocation` null).
- **Migrate:** partials (`updateConfig`, `client.config.set`, constructor `config`) need nothing. Values annotated as a complete config type must add the six fields.
- **Source:** `src/messageComposer/configuration/types.ts:66,103,109,136,149,152,160,172,178`, `src/messageComposer/configuration/configuration.ts:35-39,55,72` (v10); `src/messageComposer/configuration/types.ts:66,101,111`, `src/messageComposer/LocationComposer.ts:29` (v9)

Expand Down
2 changes: 1 addition & 1 deletion src/configuration/shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ const MESSAGE_COMPOSER_FIELDS: Record<keyof MessageComposerConfig, ConfigNode> =
},
retainCompositionOnSubmit: {
description:
"Decides whether submitting keeps the composer's contents instead of clearing them. Retains for a poll message by default.",
"Decides whether submitting keeps the composer's contents instead of clearing them. Retains for a poll message by default, while the composition chain sends polls alone.",
kind: 'value',
type: 'function',
},
Expand Down
10 changes: 9 additions & 1 deletion src/messageComposer/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
import { generateUUIDv4 } from '../../utils';
import { deepFreezeConfig } from '../../configuration/utils/deepFreezeConfig';
import { DEFAULT_COMMANDS_CONFIG } from './commands.configuration';
import { POLL_ONLY_COMPOSITION_MIDDLEWARE_ID } from '../middleware/messageComposer/pollOnly';

export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = {
debounceURLEnrichmentMs: 1500,
Expand Down Expand Up @@ -66,6 +67,13 @@ export const DEFAULT_COMPOSER_CONFIG: MessageComposerConfig = deepFreezeConfig({
linkPreviews: DEFAULT_LINK_PREVIEW_MANAGER_CONFIG,
location: DEFAULT_LOCATION_COMPOSER_CONFIG,
polls: DEFAULT_POLL_COMPOSER_CONFIG,
retainCompositionOnSubmit: (composer) => !!composer.pollId,
// Matches when the poll-only middleware sends the poll alone: never for an edit or a thread reply.
retainCompositionOnSubmit: (composer) =>
Comment thread
isekovanic marked this conversation as resolved.
Resolved
!!composer.pollId &&
!composer.editedMessage &&
!composer.threadId &&
composer.compositionMiddlewareExecutor.installedMiddleware.some(
({ id }) => id === POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
),
text: DEFAULT_TEXT_COMPOSER_CONFIG,
});
5 changes: 4 additions & 1 deletion src/messageComposer/configuration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,10 @@ export type MessageComposerConfig = {
polls: PollComposerConfig;
/**
* Whether submitting keeps the composer's contents instead of clearing them (also leaving unresolved
* uploads in place). Defaults to retaining for a poll message.
* uploads in place). Defaults to retaining when the poll is sent alone: a poll is set, the
* composer is not editing or composing a thread reply, and `POLL_ONLY_COMPOSITION_MIDDLEWARE_ID`
* is installed. The drafted text and attachments then stay for the next message. Otherwise the
* poll goes out with them, and the composer clears.
*/
retainCompositionOnSubmit: (composer: MessageComposer) => boolean;
text: TextComposerConfig;
Expand Down
1 change: 1 addition & 0 deletions src/messageComposer/middleware/messageComposer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './compositionValidation';
export * from './linkPreviews';
export * from './MessageComposerMiddlewareExecutor';
export * from './messageComposerState';
export * from './pollOnly';
export * from './sharedLocation';
export * from './textComposer';
export * from './types';
Expand Down
10 changes: 9 additions & 1 deletion src/messageComposer/middleware/messageComposer/pollOnly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@ const pollLocalMessageNullifiedFields: Pick<
text: '',
};

export const POLL_ONLY_COMPOSITION_MIDDLEWARE_ID =
'stream-io/message-composer-middleware/poll-only';

/**
* Sends a composer's poll as a message of its own (outside threads and edits), leaving the text,
* attachments and quoted message in the composer. Remove it from the composer's
* `compositionMiddlewareExecutor` to send the poll together with the rest of the message instead.
*/
export const createPollOnlyCompositionMiddleware = (
composer: MessageComposer,
): MessageCompositionMiddleware => ({
id: 'stream-io/message-composer-middleware/poll-only',
id: POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
handlers: {
compose: ({
state,
Expand Down
81 changes: 80 additions & 1 deletion test/unit/MessageComposer/messageComposer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DEFAULT_COMPOSER_CONFIG,
LocalMessage,
MessageComposerConfig,
POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
SharedLocation,
StreamChat,
Thread,
Expand Down Expand Up @@ -3303,16 +3304,45 @@ describe('MessageComposer submit lifecycle', () => {
const { messageComposer, mockChannel } = setup({ channelConfig: { polls: true } });
withText(messageComposer, 'vote please');
messageComposer.state.partialNext({ pollId: 'poll-1' });
vi.spyOn(mockChannel.messageOperations, 'send').mockResolvedValue(undefined);
const send = vi
.spyOn(mockChannel.messageOperations, 'send')
.mockResolvedValue(undefined);

expect(messageComposer.retainsCompositionOnSubmit).toBe(true);

await messageComposer.send();

expect(send.mock.calls[0][0].message).toEqual({
id: expect.any(String),
poll_id: 'poll-1',
});
expect(messageComposer.pollId).toBeNull();
expect(messageComposer.textComposer.text).toBe('vote please');
});

it('sends the poll with the text once the poll-only middleware is removed, and clears', async () => {
const { messageComposer, mockChannel } = setup({ channelConfig: { polls: true } });
messageComposer.compositionMiddlewareExecutor.remove(
POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
);
withText(messageComposer, 'vote please');
messageComposer.state.partialNext({ pollId: 'poll-1' });
const send = vi
.spyOn(mockChannel.messageOperations, 'send')
.mockResolvedValue(undefined);

expect(messageComposer.retainsCompositionOnSubmit).toBe(false);

await messageComposer.send();

expect(send.mock.calls[0][0].message).toMatchObject({
poll_id: 'poll-1',
text: 'vote please',
});
expect(messageComposer.pollId).toBeNull();
expect(messageComposer.textComposer.text).toBe('');
});

it('routes an update through messageOperations.update', async () => {
const { messageComposer, mockChannel } = setup();
withText(messageComposer);
Expand All @@ -3336,6 +3366,55 @@ describe('MessageComposerConfig.retainCompositionOnSubmit', () => {
expect(messageComposer.retainsCompositionOnSubmit).toBe(true);
});

it('does not retain for a poll while the chain sends polls with the message', () => {
const { messageComposer } = setup({ channelConfig: { polls: true } });
messageComposer.compositionMiddlewareExecutor.remove(
POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
);

messageComposer.state.partialNext({ pollId: 'poll-1' });
expect(messageComposer.retainsCompositionOnSubmit).toBe(false);
});

it('leaves edit mode after updating a message that has a poll', async () => {
const { messageComposer } = setup({
channelConfig: { polls: true },
composition: {
attachments: [],
id: 'm1',
mentioned_users: [],
poll_id: 'poll-1',
text: 'please vote',
type: 'regular',
user: { id: 'user-id' },
} as unknown as LocalMessage,
});
vi.spyOn(messageComposer.channel.messageOperations, 'update').mockResolvedValue(
undefined,
);

expect(messageComposer.pollId).toBe('poll-1');
expect(messageComposer.retainsCompositionOnSubmit).toBe(false);

messageComposer.textComposer.state.partialNext({ text: 'please vote (edited)' });
await messageComposer.update();

expect(messageComposer.editedMessage).toBeFalsy();
expect(messageComposer.textComposer.text).toBe('');
});

it('does not retain for a thread reply that carries a poll', () => {
const { mockChannel, mockClient } = setup();
const mockThread = getThread(mockChannel, mockClient, 'test-thread-id');
const { messageComposer } = setup({
channelConfig: { polls: true },
compositionContext: mockThread,
});

messageComposer.state.partialNext({ pollId: 'poll-1' });
expect(messageComposer.retainsCompositionOnSubmit).toBe(false);
});

it('lets an integrator decide, receiving the composer', async () => {
const retainCompositionOnSubmit = vi.fn(
(composer: MessageComposer) => composer.textComposer.text === 'keep me',
Expand Down
Loading