From 93adb3cfb9b51f0690bacf3beed72afe5ec94d05 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 28 Oct 2026 09:57:06 +0100 Subject: [PATCH 01/18] feat(MessageComposer): make composer previews a configurable set and add a poll preview MessageComposerPreviews renders a messageComposerPreviewSet, modelled on MessageActions' messageActionSet: each item has a type, a Component and an isVisible rule read from composer state. The preview area renders when any item is visible. defaultMessageComposerPreviewSet keeps today's previews and order and adds a poll preview, shown while messageComposer.state.pollId holds a poll not sent yet (not while editing). Integrators install their own set through the new MessageComposerPreviews component slot. PollPreview is a slot too. EditedMessagePreview, GeolocationPreview, PollPreview, useMessageComposerPreviewSetFilter and useMessageComposerPreviewState are exported from the root. Refs REACT-1441 Closes REACT-1430 --- ai-docs/ai-migration-v14-v15.md | 46 +++ examples/vite/src/i18n/de.ts | 1 + examples/vite/src/i18n/it.ts | 1 + .../AttachmentPreviewList/index.ts | 2 +- .../MessageComposerPreviews.defaults.tsx | 136 +++++++++ .../MessageComposerPreviews.tsx | 52 ++++ .../MessageComposer/MessageComposerUI.tsx | 117 +------- .../MessageComposer/PollPreview.tsx | 92 ++++++ .../MessageComposerPreviews.test.tsx | 277 ++++++++++++++++++ src/components/MessageComposer/hooks/index.ts | 2 + .../useMessageComposerPreviewSetFilter.ts | 21 ++ .../hooks/useMessageComposerPreviewState.ts | 115 ++++++++ src/components/MessageComposer/index.ts | 5 + .../styling/AttachmentPreviewThumbnail.scss | 1 + .../MessageComposer/styling/PollPreview.scss | 50 ++++ .../MessageComposer/styling/index.scss | 1 + src/context/ComponentContext.tsx | 7 + src/i18n/__tests__/catalog.fixture.json | 1 + src/i18n/keys.ts | 1 + 19 files changed, 813 insertions(+), 115 deletions(-) create mode 100644 src/components/MessageComposer/MessageComposerPreviews.defaults.tsx create mode 100644 src/components/MessageComposer/MessageComposerPreviews.tsx create mode 100644 src/components/MessageComposer/PollPreview.tsx create mode 100644 src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx create mode 100644 src/components/MessageComposer/hooks/useMessageComposerPreviewSetFilter.ts create mode 100644 src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts create mode 100644 src/components/MessageComposer/styling/PollPreview.scss diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 79aadb5873..7a99115e7a 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2132,6 +2132,52 @@ export const ComposerPreviews = ({ children }: { children: ReactNode }) => ( ); ``` +### New: composer previews are a configurable set, with a poll preview + +Not a break. 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` (`MessageComposerPreviewState`) 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`) and adds `poll`. The poll preview shows while `messageComposer.state.pollId` holds a poll created with `messageComposer.createPoll()` and not sent yet, and not while editing. It shows the poll's question and options (fetching a poll missing from `client.polls`, e.g. one restored from a draft), and its remove button detaches the poll from the composer. Replace it through the `PollPreview` component slot. + +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`, `GeolocationPreview` and `PollPreview` are now exported from the root. + --- ## 14. Request handlers diff --git a/examples/vite/src/i18n/de.ts b/examples/vite/src/i18n/de.ts index 6eb3d10326..0c096c5dcb 100644 --- a/examples/vite/src/i18n/de.ts +++ b/examples/vite/src/i18n/de.ts @@ -564,6 +564,7 @@ export const deTranslations = { 'Standortanhang entfernen', 'messageComposer.geolocationPreview.sharedLocation.title': 'Geteilter Standort', 'messageComposer.icons.attachFiles.text': 'Dateien anhängen', + 'messageComposer.pollPreview.removePoll.ariaLabel': 'Umfrage entfernen', 'messageComposer.quotedMessagePreview.cancelReply.ariaLabel': 'Antwort abbrechen', 'messageComposer.quotedMessagePreview.files.label_one': '{{ count }} Datei', 'messageComposer.quotedMessagePreview.files.label_other': '{{ count }} Dateien', diff --git a/examples/vite/src/i18n/it.ts b/examples/vite/src/i18n/it.ts index 75c7705c3f..aa9bf197bf 100644 --- a/examples/vite/src/i18n/it.ts +++ b/examples/vite/src/i18n/it.ts @@ -558,6 +558,7 @@ export const itTranslations = { 'Rimuovi l’allegato di posizione', 'messageComposer.geolocationPreview.sharedLocation.title': 'Posizione condivisa', 'messageComposer.icons.attachFiles.text': 'Allega file', + 'messageComposer.pollPreview.removePoll.ariaLabel': 'Rimuovi sondaggio', 'messageComposer.quotedMessagePreview.cancelReply.ariaLabel': 'Annulla risposta', 'messageComposer.quotedMessagePreview.files.label_one': '{{ count }} file', 'messageComposer.quotedMessagePreview.files.label_other': '{{ count }} file', diff --git a/src/components/MessageComposer/AttachmentPreviewList/index.ts b/src/components/MessageComposer/AttachmentPreviewList/index.ts index e1fb8742a0..d72fe34be1 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 0000000000..f8214cbe9f --- /dev/null +++ b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx @@ -0,0 +1,136 @@ +/* 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 { PollPreview as DefaultPollPreview } from './PollPreview'; +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 ( + + ); + }, + Poll() { + const { PollPreview = DefaultPollPreview } = useComponentContext(); + 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', + }, + { + Component: DefaultMessageComposerPreviewComponents.Poll, + // An edited message keeps its poll; it cannot be replaced or removed. + isVisible: ({ editedMessage, pollId }) => !editedMessage && !!pollId, + type: 'poll', + }, +]; diff --git a/src/components/MessageComposer/MessageComposerPreviews.tsx b/src/components/MessageComposer/MessageComposerPreviews.tsx new file mode 100644 index 0000000000..9d96b23189 --- /dev/null +++ b/src/components/MessageComposer/MessageComposerPreviews.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { useMessageComposerPreviewSetFilter } from './hooks/useMessageComposerPreviewSetFilter'; +import type { MessageComposerPreviewState } 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' + | 'poll' + | '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: MessageComposerPreviewState) => 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, poll) 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 b3fefc2c49..dfad78ad78 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/PollPreview.tsx b/src/components/MessageComposer/PollPreview.tsx new file mode 100644 index 0000000000..2c4dd435e3 --- /dev/null +++ b/src/components/MessageComposer/PollPreview.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useState } from 'react'; +import type { MessageComposerState, Poll, PollState } from 'stream-chat'; + +import { useChatContext } from '../../context/ChatContext'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; +import { useStateStore } from '../../store'; +import { useMessageComposerController } from './hooks'; +import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; + +const messageComposerStateSelector = ({ pollId }: MessageComposerState) => ({ pollId }); + +const pollStateSelector = ({ name, options }: PollState) => ({ name, options }); + +const PollPreviewImage = () => { + const { IconPoll } = useComponentContextIcons(); + + return ( +
+ +
+ ); +}; + +const PollPreviewData = ({ poll }: { poll: Poll }) => { + const { name, options } = useStateStore(poll.state, pollStateSelector); + + return ( +
+
+ {name} +
+
+ {options?.map(({ text }) => text).join(', ')} +
+
+ ); +}; + +/** + * Preview of the poll the composer holds (`messageComposer.state.pollId`), which goes out with + * the message. Shows the poll's question and options, 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 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); + + useEffect(() => { + if (!pollId || client.polls.fromState(pollId)) return; + let cancelled = false; + client.polls + .getPoll(pollId) + .then((poll) => { + if (!cancelled) setFetchedPoll(poll); + }) + .catch(() => { + // The preview keeps its placeholder; the poll still goes out with the message. + }); + return () => { + cancelled = true; + }; + }, [client, pollId]); + + if (!pollId) return null; + + return ( +
+ + {poll ? ( + + ) : ( +
+
+ {t('common.poll.label', 'Poll')} +
+
+ )} + messageComposer.state.partialNext({ pollId: null })} + /> +
+ ); +}; diff --git a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx new file mode 100644 index 0000000000..4143a5c792 --- /dev/null +++ b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx @@ -0,0 +1,277 @@ +import React from 'react'; +import { act, cleanup, fireEvent, 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 cachePoll = (client: StreamChat, poll = generatePoll()) => { + client.polls.hydratePollCache([fromPartial({ poll })]); + return poll; +}; + +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='poll-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 }); + cachePoll(client); + + await act(() => { + channel.messageComposer.setQuotedMessage( + generateMessage({ text: 'quoted', user: generateUser() }), + ); + addFileAttachment(channel); + addLinkPreview(channel); + channel.messageComposer.locationComposer.setData({ latitude: 1, longitude: 2 }); + channel.messageComposer.state.partialNext({ pollId: generatePoll().id }); + }); + + expect(container.querySelectorAll(`.${PREVIEWS_CLASS}`)).toHaveLength(1); + expect(previewTestIds(container)).toEqual([ + 'quoted-message-preview', + 'attachment-preview-list', + 'link-preview-card', + 'location-preview', + 'poll-preview', + ]); + }); + + it('shows the edited message instead of the quoted message and the poll', 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() }), + ), + pollId: 'poll-id', + quotedMessage: fromPartial(generateMessage({ text: 'quoted' })), + }); + }); + + expect(screen.getByText('Edit Message')).toBeInTheDocument(); + expect(screen.queryByText('quoted')).not.toBeInTheDocument(); + expect(screen.queryByTestId('poll-preview')).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(); + }); + + describe('poll preview', () => { + it('renders the preview area for a poll-only composer', async () => { + const { channel, client } = await setup(); + const poll = cachePoll(client); + const { container } = await renderPreviews({ channel, client }); + + await act(() => { + channel.messageComposer.state.partialNext({ pollId: poll.id }); + }); + + const pollPreview = screen.getByTestId('poll-preview'); + expect(container.querySelector(`.${PREVIEWS_CLASS}`)).toContainElement(pollPreview); + expect(pollPreview).toHaveTextContent(poll.name); + expect(pollPreview).toHaveTextContent( + poll.options.map(({ text }) => text).join(', '), + ); + }); + + it('fetches a poll that is not cached', async () => { + const { channel, client } = await setup(); + const poll = generatePoll({ id: 'uncached-poll', name: 'Fetched question' }); + const getPollSpy = vi + .spyOn(client, 'getPoll') + .mockResolvedValue(fromPartial({ poll })); + await renderPreviews({ channel, client }); + + await act(() => { + channel.messageComposer.state.partialNext({ pollId: poll.id }); + }); + + expect(getPollSpy).toHaveBeenCalledWith({ poll_id: poll.id }); + expect(await screen.findByText('Fetched question')).toBeInTheDocument(); + }); + + it('detaches the poll from the composer on remove', async () => { + const { channel, client } = await setup(); + const poll = cachePoll(client); + const { container } = await renderPreviews({ channel, client }); + + await act(() => { + channel.messageComposer.state.partialNext({ pollId: poll.id }); + }); + await act(() => { + fireEvent.click(screen.getByTestId('poll-preview-delete-button')); + }); + + expect(channel.messageComposer.pollId).toBeNull(); + expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument(); + }); + + it('has no accessibility violations', async () => { + const { channel, client } = await setup(); + const poll = cachePoll(client); + const { container } = await renderPreviews({ channel, client }); + + await act(() => { + channel.messageComposer.state.partialNext({ pollId: poll.id }); + addLinkPreview(channel); + }); + + expect(await axe(container)).toHaveNoViolations(); + }); + }); +}); diff --git a/src/components/MessageComposer/hooks/index.ts b/src/components/MessageComposer/hooks/index.ts index e4cd3d4629..49667f6595 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 0000000000..7edd815164 --- /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 0000000000..336488d6b9 --- /dev/null +++ b/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts @@ -0,0 +1,115 @@ +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 MessageComposerPreviewState = { + 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()` that goes out with the message. */ + pollId: MessageComposerState['pollId']; + quotedMessage: MessageComposerState['quotedMessage']; +}; + +/** + * Subscribes to the composer state that decides which previews show, for the composer + * `useMessageComposerController()` resolves. + */ +export const useMessageComposerPreviewState = (): MessageComposerPreviewState => { + 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 5d19f518c5..4e48cb7bf8 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,12 +17,16 @@ 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 './PollPreview'; export * from './QuotedMessagePreview'; export * from './RemoveAttachmentPreviewButton'; export * from './SendButton'; diff --git a/src/components/MessageComposer/styling/AttachmentPreviewThumbnail.scss b/src/components/MessageComposer/styling/AttachmentPreviewThumbnail.scss index a79d129585..a8d47a6976 100644 --- a/src/components/MessageComposer/styling/AttachmentPreviewThumbnail.scss +++ b/src/components/MessageComposer/styling/AttachmentPreviewThumbnail.scss @@ -1,5 +1,6 @@ // todo: should we have img dimensions determined by semantic variables? .str-chat__location-preview-image, +.str-chat__poll-preview-image, .str-chat__link-preview-card .str-chat__image-placeholder, .str-chat__attachment-preview__thumbnail { border-radius: var(--str-chat__radius-md); diff --git a/src/components/MessageComposer/styling/PollPreview.scss b/src/components/MessageComposer/styling/PollPreview.scss new file mode 100644 index 0000000000..88965cfecf --- /dev/null +++ b/src/components/MessageComposer/styling/PollPreview.scss @@ -0,0 +1,50 @@ +@use '../../../styling/utils'; + +.str-chat__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); + + .str-chat__poll-preview-image { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--str-chat__background-core-overlay-light); + width: 40px; + height: 40px; + + .str-chat__icon { + width: 20px; + height: 20px; + } + } + + .str-chat__poll-preview__data { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + flex: 1; + height: 100%; + + .str-chat__poll-preview__data__title, + .str-chat__poll-preview__data__subtitle { + @include utils.ellipsis-text(); + } + + .str-chat__poll-preview__data__title { + font: var(--str-chat__font-metadata-emphasis); + } + .str-chat__poll-preview__data__subtitle { + font: var(--str-chat__font-metadata-default); + } + } +} diff --git a/src/components/MessageComposer/styling/index.scss b/src/components/MessageComposer/styling/index.scss index ea2fc8891c..d30e925318 100644 --- a/src/components/MessageComposer/styling/index.scss +++ b/src/components/MessageComposer/styling/index.scss @@ -7,6 +7,7 @@ @use 'GeologacationPreview'; @use 'LinkPreviewList'; @use 'MessageComposer'; +@use 'PollPreview'; @use 'QuotedMessageIndicator'; @use 'QuotedMessagePreview'; @use 'RemoveAttachmentPreviewButton'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index c95b1d3180..044f5bc664 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -190,6 +190,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. */ @@ -244,6 +249,8 @@ export type ComponentContextValue = { */ MessageAlsoSentInChannelIndicator?: React.ComponentType; PinIndicator?: React.ComponentType; + /** Preview of the poll the message composer holds (`messageComposer.state.pollId`). */ + PollPreview?: React.ComponentType; /** Poll actions in a message. */ PollActions?: React.ComponentType; /** Poll rendering in a message. */ diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json index 0f6be75507..da3888b139 100644 --- a/src/i18n/__tests__/catalog.fixture.json +++ b/src/i18n/__tests__/catalog.fixture.json @@ -407,6 +407,7 @@ "messageComposer.geolocationPreview.removeLocationAttachment.ariaLabel": "Remove location attachment", "messageComposer.geolocationPreview.sharedLocation.title": "Shared location", "messageComposer.icons.attachFiles.text": "Attach files", + "messageComposer.pollPreview.removePoll.ariaLabel": "Remove poll", "messageComposer.quotedMessagePreview.cancelReply.ariaLabel": "Cancel Reply", "messageComposer.quotedMessagePreview.files.label_one": "{{ count }} file", "messageComposer.quotedMessagePreview.files.label_other": "{{ count }} files", diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index 362e512695..e0d4eb5ac0 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -418,6 +418,7 @@ export type TranslationCatalog = { 'messageComposer.geolocationPreview.removeLocationAttachment.ariaLabel': 'Remove location attachment'; 'messageComposer.geolocationPreview.sharedLocation.title': 'Shared location'; 'messageComposer.icons.attachFiles.text': 'Attach files'; + 'messageComposer.pollPreview.removePoll.ariaLabel': 'Remove poll'; 'messageComposer.quotedMessagePreview.cancelReply.ariaLabel': 'Cancel Reply'; 'messageComposer.quotedMessagePreview.files.label_one': '{{ count }} file'; 'messageComposer.quotedMessagePreview.files.label_other': '{{ count }} files'; From 699b505fff3d8820a9515f7b070ae26e0e374b99 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 28 Oct 2026 10:06:09 +0100 Subject: [PATCH 02/18] chore(examples): configure the composer's preview sections in the vite settings Settings -> Composer lists the composer's preview sections, built from defaultMessageComposerPreviewSet. Drag a section (or use its arrows) to reorder it, or switch it off. The composer renders the result through the MessageComposerPreviews slot. A removed section also removes the composer's way into it: Files drops the selector's File option and rejects dropped or pasted files, Poll and Location turn off their composer config, Link previews stops scraping, Voice recording hides the record button, and Quoted and Edited message drop the Quote and Edit actions. Refs REACT-1441 --- examples/vite/src/App.tsx | 42 +++++- .../vite/src/AppSettings/AppSettings.scss | 59 ++++++++ examples/vite/src/AppSettings/state.ts | 15 +- .../AppSettings/tabs/Composer/ComposerTab.tsx | 15 ++ .../tabs/Composer/PreviewSectionsField.tsx | 137 ++++++++++++++++++ .../src/CommandModeAttachmentSelector.tsx | 17 +++ .../ConfigurableComposerPreviews.tsx | 34 +++++ .../src/ComposerPreviews/previewSections.ts | 68 +++++++++ .../ConfigurableMessageActions.tsx | 18 ++- 9 files changed, 396 insertions(+), 9 deletions(-) create mode 100644 examples/vite/src/AppSettings/tabs/Composer/PreviewSectionsField.tsx create mode 100644 examples/vite/src/ComposerPreviews/ConfigurableComposerPreviews.tsx create mode 100644 examples/vite/src/ComposerPreviews/previewSections.ts diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 9f5db4a600..bbc3f260e3 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -22,6 +22,7 @@ import { createCommandStringExtractionMiddleware, createDraftCommandInjectionMiddleware, createPriorityOwnershipResolver, + isVoiceRecordingAttachment, MessageSearchSource, SearchController, UserSearchSource, @@ -96,6 +97,10 @@ 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 { StreamDebugHandles } from './Debug'; import { installUploadHarness, @@ -291,8 +296,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 +587,28 @@ 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 }, }); }); - }, [chatClient, failUploads, slowUploads, uploadDestination]); + }, [ + chatClient, + failUploads, + filesDisabled, + linkPreviewsDisabled, + locationDisabled, + pollDisabled, + slowUploads, + uploadDestination, + ]); useEffect(() => { if (!chatClient) return; @@ -671,6 +701,8 @@ const App = () => { HeaderEndContent: SidebarToggle, MessageActions: ConfigurableMessageActions, AttachmentSelector: CommandModeAttachmentSelector, + MessageComposerPreviews: ConfigurableComposerPreviews, + StartRecordingAudioButton: ConfigurableStartRecordingAudioButton, MessageUI: InlineEditableMessage, ...messageUiOverrides, }} diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index fc87ff868a..4bb9efe61b 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -1263,6 +1263,65 @@ padding: 10px 12px; } + // Settings -> Composer -> Preview sections: one draggable row per section, in render order. + .app__composer-preview-sections { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-xs); + margin: 0; + padding: 0; + list-style: none; + } + + .app__composer-preview-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__composer-preview-sections__text { + opacity: 0.55; + } + } + + .app__composer-preview-sections__handle { + display: flex; + color: var(--str-chat__text-tertiary, var(--str-chat__text-secondary)); + } + + .app__composer-preview-sections__label { + font-size: 13px; + font-weight: 600; + color: var(--str-chat__text-primary); + } + + .app__composer-preview-sections__comment { + color: var(--str-chat__text-secondary); + font-size: 12px; + line-height: 1.4; + } + + .app__composer-preview-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/state.ts b/examples/vite/src/AppSettings/state.ts index 4d2a78194f..38b5fcd50d 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -1,5 +1,5 @@ import { StateStore } from '@stream-io/state-store'; -import { useStateStore } from 'stream-chat-react'; +import { defaultMessageComposerPreviewSet, useStateStore } from 'stream-chat-react'; import { DEFAULT_LANGUAGE, streamI18n } from '../i18n'; import type { UploadDestination, UploadFailureMode } from '../SendWhilePendingUploads'; @@ -102,6 +102,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. */ @@ -185,7 +196,9 @@ const defaultAppSettingsState: AppSettingsState = { }, composer: { customCdnUrl: '', + disabledPreviewSections: [], failUploads: 'off', + previewSectionOrder: defaultMessageComposerPreviewSet.map(({ type }) => type), sendMessagesWithPendingUploads: false, slowUploadMs: 20000, slowUploads: false, diff --git a/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx b/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx index b295909eb8..6797b4b9a3 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,20 @@ export const ComposerTab = ({ close }: ComposerTabProps) => { /> +
+
Preview sections
+
+ The previews above the textarea, rendered through{' '} + MessageComposerPreviews with a{' '} + messageComposerPreviewSet built from{' '} + defaultMessageComposerPreviewSet. 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 next = [...order]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +}; + +/** + * The composer's preview sections as a list to drag into order and switch on or off. Up and down + * buttons do the same from the keyboard. + */ +export const PreviewSectionsField = () => { + const { composer } = useAppSettingsState(); + const order = resolvePreviewSectionOrder(composer.previewSectionOrder); + const { disabledPreviewSections } = composer; + const [draggedType, setDraggedType] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + + const setOrder = (previewSectionOrder: string[]) => + appSettingsStore.partialNext({ composer: { ...composer, previewSectionOrder } }); + + const setEnabled = (type: string, enabled: boolean) => + appSettingsStore.partialNext({ + composer: { + ...composer, + disabledPreviewSections: enabled + ? disabledPreviewSections.filter((disabled) => disabled !== type) + : [...disabledPreviewSections, type], + }, + }); + + const endDrag = () => { + setDraggedType(null); + setDropIndex(null); + }; + + return ( +
    { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setDropIndex(null); + } + }} + > + {order.map((type, index) => { + const { label, removes } = + PREVIEW_SECTION_INFO[type as DefaultMessageComposerPreviewType]; + const enabled = !disabledPreviewSections.includes(type); + + return ( +
  1. { + 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) + setOrder(moveItem(order, order.indexOf(draggedType), index)); + endDrag(); + }} + > + + + +
    +
    {label}
    +
    + {type}. Removing it also removes: {removes} +
    +
    +
    + + + setEnabled(type, event.target.checked)} + /> +
    +
  2. + ); + })} +
+ ); +}; diff --git a/examples/vite/src/CommandModeAttachmentSelector.tsx b/examples/vite/src/CommandModeAttachmentSelector.tsx index 080856e7f2..1a3357eaf5 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/previewSections.ts b/examples/vite/src/ComposerPreviews/previewSections.ts new file mode 100644 index 0000000000..2369dc1535 --- /dev/null +++ b/examples/vite/src/ComposerPreviews/previewSections.ts @@ -0,0 +1,68 @@ +import { + defaultMessageComposerPreviewSet, + type DefaultMessageComposerPreviewType, + type MessageComposerPreviewSetItem, +} from 'stream-chat-react'; + +type PreviewSectionInfo = { + label: string; + /** What else leaving the section out turns off, so the composer never holds what it cannot show. */ + removes: string; +}; + +export const PREVIEW_SECTION_INFO: Record< + DefaultMessageComposerPreviewType, + PreviewSectionInfo +> = { + 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.', + }, + quotedMessage: { + label: 'Quoted message', + removes: 'The Quote message action.', + }, + voiceRecording: { + label: 'Voice recording', + removes: 'The record button.', + }, +}; + +const DEFAULT_TYPES = defaultMessageComposerPreviewSet.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)), +]; + +/** `defaultMessageComposerPreviewSet` reordered and filtered by the Composer settings. */ +export const buildPreviewSet = ( + order: string[], + disabled: string[], +): MessageComposerPreviewSetItem[] => + resolvePreviewSectionOrder(order) + .filter((type) => !disabled.includes(type)) + .flatMap((type) => + defaultMessageComposerPreviewSet.filter((item) => item.type === type), + ); diff --git a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx index e23aeecfa3..efc5d5433a 100644 --- a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx +++ b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx @@ -369,6 +369,11 @@ 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 customDeleteEnabled = surfaceActions.delete.enableOptionConfiguration; const configurableActionSet = useMemo(() => { const actionSet = props.messageActionSet ?? defaultMessageActionSet; @@ -438,16 +443,23 @@ 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, + quoteDisabled, surfaceActions.disabledActionTypes, surfaceActions.inlineEdit, surfaceActions.markOwnUnread, From 1a2b13cf89146a7ee07ac69db110771e81fe70bb Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 28 Oct 2026 10:11:16 +0100 Subject: [PATCH 03/18] feat(Poll): make the poll creation dialog footer a component slot PollCreationDialog renders its footer through the new PollCreationDialogControls slot. PollCreationDialogControls and its props type are exported, so an integrator can replace only the footer, e.g. with a button that calls messageComposer.createPoll() without send() to attach the poll to the message being composed. Refs REACT-1441 --- ai-docs/ai-migration-v14-v15.md | 41 ++++++++++++ .../PollCreationDialog/PollCreationDialog.tsx | 6 +- .../Poll/PollCreationDialog/index.ts | 1 + .../__tests__/PollCreationDialog.test.tsx | 65 ++++++++++++++++++- src/context/ComponentContext.tsx | 7 ++ 5 files changed, 116 insertions(+), 4 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 7a99115e7a..a687ab1ef1 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2178,6 +2178,47 @@ export const App = ({ children }: { children: React.ReactNode }) => ( - `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`, `GeolocationPreview` and `PollPreview` are now exported from the root. +The default poll creation dialog sends the poll as soon as it is created, so the poll preview only shows a poll created elsewhere. To have the dialog attach the poll to the message being composed instead, replace its 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 + + { + // The poll waits in `messageComposer.state.pollId` and goes out with the message. + await messageComposer.createPoll(); + close(); + }} + type='submit' + > + Attach poll + + + + ); +}; + +export const App = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); +``` + --- ## 14. Request handlers diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx index e0d2e7bedb..eba871933b 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 e4eb0a0a99..87a02d8afc 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 8a96e468b0..30c28bf371 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 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 044f5bc664..d98e37badb 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, @@ -257,6 +258,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. */ From 16840bc8d574b04aaa0239e82a1180d8aec27a78 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 28 Oct 2026 10:18:32 +0100 Subject: [PATCH 04/18] chore(examples): attach polls to the message while the Poll preview is shown While Settings -> Composer shows the Poll preview section, the vite app replaces the poll creation dialog's footer through the PollCreationDialogControls slot with an "Attach poll" button. It calls messageComposer.createPoll() without send(), so the poll waits in the composer's preview and goes out with the next message. Refs REACT-1441 --- examples/vite/src/App.tsx | 2 + ...ConfigurablePollCreationDialogControls.tsx | 77 +++++++++++++++++++ .../src/ComposerPreviews/previewSections.ts | 3 +- 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index bbc3f260e3..c09a0b5e1b 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -101,6 +101,7 @@ import { ConfigurableComposerPreviews, ConfigurableStartRecordingAudioButton, } from './ComposerPreviews/ConfigurableComposerPreviews.tsx'; +import { ConfigurablePollCreationDialogControls } from './ComposerPreviews/ConfigurablePollCreationDialogControls.tsx'; import { StreamDebugHandles } from './Debug'; import { installUploadHarness, @@ -702,6 +703,7 @@ const App = () => { MessageActions: ConfigurableMessageActions, AttachmentSelector: CommandModeAttachmentSelector, MessageComposerPreviews: ConfigurableComposerPreviews, + PollCreationDialogControls: ConfigurablePollCreationDialogControls, StartRecordingAudioButton: ConfigurableStartRecordingAudioButton, MessageUI: InlineEditableMessage, ...messageUiOverrides, diff --git a/examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx b/examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx new file mode 100644 index 0000000000..a75562ef2f --- /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/previewSections.ts b/examples/vite/src/ComposerPreviews/previewSections.ts index 2369dc1535..e5dc958419 100644 --- a/examples/vite/src/ComposerPreviews/previewSections.ts +++ b/examples/vite/src/ComposerPreviews/previewSections.ts @@ -33,7 +33,8 @@ export const PREVIEW_SECTION_INFO: Record< }, poll: { label: 'Poll', - removes: 'The Poll option in the attachment selector.', + removes: + 'The Poll option in the attachment selector. While shown, the poll dialog attaches the poll to the message instead of sending it.', }, quotedMessage: { label: 'Quoted message', From b7022cff4060a429a56926c53d51d857ed10f278 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 28 Oct 2026 10:29:55 +0100 Subject: [PATCH 05/18] feat(MessageComposer): show the poll's settings and a capped option list in the poll preview The poll preview shows the option count, names the first three options and counts the rest (a poll holds up to 100), and lists the poll's settings as tags: multiple votes or the vote limit, anonymous voting, user-suggested options and comments. Refs REACT-1441 --- examples/vite/src/i18n/de.ts | 13 +++ examples/vite/src/i18n/it.ts | 13 +++ .../MessageComposer/PollPreview.tsx | 93 ++++++++++++++++++- .../MessageComposerPreviews.test.tsx | 44 ++++++++- .../MessageComposer/styling/PollPreview.scss | 40 +++++++- src/i18n/__tests__/catalog.fixture.json | 11 +++ src/i18n/keys.ts | 11 +++ 7 files changed, 212 insertions(+), 13 deletions(-) diff --git a/examples/vite/src/i18n/de.ts b/examples/vite/src/i18n/de.ts index 0c096c5dcb..de99478249 100644 --- a/examples/vite/src/i18n/de.ts +++ b/examples/vite/src/i18n/de.ts @@ -564,7 +564,20 @@ export const deTranslations = { 'Standortanhang entfernen', 'messageComposer.geolocationPreview.sharedLocation.title': 'Geteilter Standort', 'messageComposer.icons.attachFiles.text': 'Dateien anhängen', + 'messageComposer.pollPreview.anonymous.text': 'Anonym', + 'messageComposer.pollPreview.comments.text': 'Kommentare', + 'messageComposer.pollPreview.listedOptions.withMore.text_one': + '{{ options }} und {{ count }} weitere', + 'messageComposer.pollPreview.listedOptions.withMore.text_other': + '{{ options }} und {{ count }} weitere', + 'messageComposer.pollPreview.maxVotes.text_one': 'Bis zu {{ count }} Stimme', + 'messageComposer.pollPreview.maxVotes.text_other': 'Bis zu {{ count }} Stimmen', + 'messageComposer.pollPreview.multipleVotes.text': 'Mehrere Stimmen', + 'messageComposer.pollPreview.optionCount.text_one': '{{ count }} Option', + 'messageComposer.pollPreview.optionCount.text_other': '{{ count }} Optionen', 'messageComposer.pollPreview.removePoll.ariaLabel': 'Umfrage entfernen', + 'messageComposer.pollPreview.settings.ariaLabel': 'Umfrageeinstellungen', + 'messageComposer.pollPreview.suggestOptions.text': 'Andere können Optionen hinzufügen', 'messageComposer.quotedMessagePreview.cancelReply.ariaLabel': 'Antwort abbrechen', 'messageComposer.quotedMessagePreview.files.label_one': '{{ count }} Datei', 'messageComposer.quotedMessagePreview.files.label_other': '{{ count }} Dateien', diff --git a/examples/vite/src/i18n/it.ts b/examples/vite/src/i18n/it.ts index aa9bf197bf..de6445deaf 100644 --- a/examples/vite/src/i18n/it.ts +++ b/examples/vite/src/i18n/it.ts @@ -558,7 +558,20 @@ export const itTranslations = { 'Rimuovi l’allegato di posizione', 'messageComposer.geolocationPreview.sharedLocation.title': 'Posizione condivisa', 'messageComposer.icons.attachFiles.text': 'Allega file', + 'messageComposer.pollPreview.anonymous.text': 'Anonimo', + 'messageComposer.pollPreview.comments.text': 'Commenti', + 'messageComposer.pollPreview.listedOptions.withMore.text_one': + '{{ options }} e {{ count }} altra', + 'messageComposer.pollPreview.listedOptions.withMore.text_other': + '{{ options }} e altre {{ count }}', + 'messageComposer.pollPreview.maxVotes.text_one': 'Fino a {{ count }} voto', + 'messageComposer.pollPreview.maxVotes.text_other': 'Fino a {{ count }} voti', + 'messageComposer.pollPreview.multipleVotes.text': 'Voti multipli', + 'messageComposer.pollPreview.optionCount.text_one': '{{ count }} opzione', + 'messageComposer.pollPreview.optionCount.text_other': '{{ count }} opzioni', 'messageComposer.pollPreview.removePoll.ariaLabel': 'Rimuovi sondaggio', + 'messageComposer.pollPreview.settings.ariaLabel': 'Impostazioni del sondaggio', + 'messageComposer.pollPreview.suggestOptions.text': 'Altri possono aggiungere opzioni', 'messageComposer.quotedMessagePreview.cancelReply.ariaLabel': 'Annulla risposta', 'messageComposer.quotedMessagePreview.files.label_one': '{{ count }} file', 'messageComposer.quotedMessagePreview.files.label_other': '{{ count }} file', diff --git a/src/components/MessageComposer/PollPreview.tsx b/src/components/MessageComposer/PollPreview.tsx index 2c4dd435e3..0e893791f8 100644 --- a/src/components/MessageComposer/PollPreview.tsx +++ b/src/components/MessageComposer/PollPreview.tsx @@ -9,7 +9,26 @@ import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; const messageComposerStateSelector = ({ pollId }: MessageComposerState) => ({ pollId }); -const pollStateSelector = ({ name, options }: PollState) => ({ name, options }); +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, +}); + +/** Options named in the preview. A poll holds up to 100, and the rest are only counted. */ +const LISTED_OPTION_COUNT = 3; const PollPreviewImage = () => { const { IconPoll } = useComponentContextIcons(); @@ -22,16 +41,80 @@ const PollPreviewImage = () => { }; const PollPreviewData = ({ poll }: { poll: Poll }) => { - const { name, options } = useStateStore(poll.state, pollStateSelector); + 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('messageComposer.pollPreview.maxVotes.text', { + count: max_votes_allowed, + defaultValue_one: 'Up to {{ count }} vote', + defaultValue_other: 'Up to {{ count }} votes', + }) + : t('messageComposer.pollPreview.multipleVotes.text', 'Multiple votes')), + voting_visibility === 'anonymous' && + t('messageComposer.pollPreview.anonymous.text', 'Anonymous'), + allow_user_suggested_options && + t('messageComposer.pollPreview.suggestOptions.text', 'Others can add options'), + allow_answers && t('messageComposer.pollPreview.comments.text', 'Comments'), + ].filter((setting): setting is string => !!setting); return (
{name}
-
- {options?.map(({ text }) => text).join(', ')} -
+ {options.length > 0 && ( +
+ + {t('messageComposer.pollPreview.optionCount.text', { + count: options.length, + defaultValue_one: '{{ count }} option', + defaultValue_other: '{{ count }} options', + })} + + + {unlistedOptionCount > 0 + ? t('messageComposer.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} +
  • + ))} +
+ )}
); }; diff --git a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx index 4143a5c792..e0aa659599 100644 --- a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, within } 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'; @@ -224,9 +224,47 @@ describe('MessageComposerPreviews', () => { const pollPreview = screen.getByTestId('poll-preview'); expect(container.querySelector(`.${PREVIEWS_CLASS}`)).toContainElement(pollPreview); expect(pollPreview).toHaveTextContent(poll.name); - expect(pollPreview).toHaveTextContent( - poll.options.map(({ text }) => text).join(', '), + // The default generated poll: 4 options, up to 2 votes, public, comments on. + expect(pollPreview).toHaveTextContent('4 options'); + expect(pollPreview).toHaveTextContent('A, B, C and 1 more'); + expect( + within(screen.getByRole('list', { name: 'Poll settings' })) + .getAllByRole('listitem') + .map((item) => item.textContent), + ).toEqual(['Up to 2 votes', 'Comments']); + }); + + it('names only the first options of a long poll and reflects its settings', async () => { + const { channel, client } = await setup(); + const poll = cachePoll( + client, + generatePoll({ + allow_answers: false, + allow_user_suggested_options: true, + enforce_unique_vote: true, + id: 'long-poll', + options: Array.from({ length: 99 }, (_, index) => ({ + id: `option-${index}`, + text: `Option ${index + 1}`, + })), + voting_visibility: 'anonymous', + }), ); + await renderPreviews({ channel, client }); + + await act(() => { + channel.messageComposer.state.partialNext({ pollId: poll.id }); + }); + + const pollPreview = screen.getByTestId('poll-preview'); + expect(pollPreview).toHaveTextContent('99 options'); + expect(pollPreview).toHaveTextContent('Option 1, Option 2, Option 3 and 96 more'); + expect(pollPreview).not.toHaveTextContent('Option 4'); + expect( + within(screen.getByRole('list', { name: 'Poll settings' })) + .getAllByRole('listitem') + .map((item) => item.textContent), + ).toEqual(['Anonymous', 'Others can add options']); }); it('fetches a poll that is not cached', async () => { diff --git a/src/components/MessageComposer/styling/PollPreview.scss b/src/components/MessageComposer/styling/PollPreview.scss index 88965cfecf..1739ffbc49 100644 --- a/src/components/MessageComposer/styling/PollPreview.scss +++ b/src/components/MessageComposer/styling/PollPreview.scss @@ -34,17 +34,47 @@ min-width: 0; flex: 1; height: 100%; + gap: 2px; - .str-chat__poll-preview__data__title, - .str-chat__poll-preview__data__subtitle { + .str-chat__poll-preview__data__title { @include utils.ellipsis-text(); + font: var(--str-chat__font-metadata-emphasis); } - .str-chat__poll-preview__data__title { - font: var(--str-chat__font-metadata-emphasis); + .str-chat__poll-preview__data__options { + display: flex; + gap: var(--str-chat__spacing-xxs); + min-width: 0; + font: var(--str-chat__font-metadata-default); + } + + .str-chat__poll-preview__data__options-count { + flex-shrink: 0; + color: var(--str-chat__text-primary); } - .str-chat__poll-preview__data__subtitle { + + // A poll holds up to 100 options; only the first few are named and the line still ellipsizes. + .str-chat__poll-preview__data__options-list { + @include utils.ellipsis-text(); + min-width: 0; + color: var(--str-chat__text-secondary); + } + + .str-chat__poll-preview__data__settings { + display: flex; + flex-wrap: wrap; + gap: var(--str-chat__spacing-xxs); + margin: 2px 0 0; + padding: 0; + list-style: none; + } + + .str-chat__poll-preview__data__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/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json index da3888b139..80bf9aeec4 100644 --- a/src/i18n/__tests__/catalog.fixture.json +++ b/src/i18n/__tests__/catalog.fixture.json @@ -407,7 +407,18 @@ "messageComposer.geolocationPreview.removeLocationAttachment.ariaLabel": "Remove location attachment", "messageComposer.geolocationPreview.sharedLocation.title": "Shared location", "messageComposer.icons.attachFiles.text": "Attach files", + "messageComposer.pollPreview.anonymous.text": "Anonymous", + "messageComposer.pollPreview.comments.text": "Comments", + "messageComposer.pollPreview.listedOptions.withMore.text_one": "{{ options }} and {{ count }} more", + "messageComposer.pollPreview.listedOptions.withMore.text_other": "{{ options }} and {{ count }} more", + "messageComposer.pollPreview.maxVotes.text_one": "Up to {{ count }} vote", + "messageComposer.pollPreview.maxVotes.text_other": "Up to {{ count }} votes", + "messageComposer.pollPreview.multipleVotes.text": "Multiple votes", + "messageComposer.pollPreview.optionCount.text_one": "{{ count }} option", + "messageComposer.pollPreview.optionCount.text_other": "{{ count }} options", "messageComposer.pollPreview.removePoll.ariaLabel": "Remove poll", + "messageComposer.pollPreview.settings.ariaLabel": "Poll settings", + "messageComposer.pollPreview.suggestOptions.text": "Others can add options", "messageComposer.quotedMessagePreview.cancelReply.ariaLabel": "Cancel Reply", "messageComposer.quotedMessagePreview.files.label_one": "{{ count }} file", "messageComposer.quotedMessagePreview.files.label_other": "{{ count }} files", diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index e0d4eb5ac0..1fe9a4ba0d 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -418,7 +418,18 @@ export type TranslationCatalog = { 'messageComposer.geolocationPreview.removeLocationAttachment.ariaLabel': 'Remove location attachment'; 'messageComposer.geolocationPreview.sharedLocation.title': 'Shared location'; 'messageComposer.icons.attachFiles.text': 'Attach files'; + 'messageComposer.pollPreview.anonymous.text': 'Anonymous'; + 'messageComposer.pollPreview.comments.text': 'Comments'; + 'messageComposer.pollPreview.listedOptions.withMore.text_one': '{{ options }} and {{ count }} more'; + 'messageComposer.pollPreview.listedOptions.withMore.text_other': '{{ options }} and {{ count }} more'; + 'messageComposer.pollPreview.maxVotes.text_one': 'Up to {{ count }} vote'; + 'messageComposer.pollPreview.maxVotes.text_other': 'Up to {{ count }} votes'; + 'messageComposer.pollPreview.multipleVotes.text': 'Multiple votes'; + 'messageComposer.pollPreview.optionCount.text_one': '{{ count }} option'; + 'messageComposer.pollPreview.optionCount.text_other': '{{ count }} options'; 'messageComposer.pollPreview.removePoll.ariaLabel': 'Remove poll'; + 'messageComposer.pollPreview.settings.ariaLabel': 'Poll settings'; + 'messageComposer.pollPreview.suggestOptions.text': 'Others can add options'; 'messageComposer.quotedMessagePreview.cancelReply.ariaLabel': 'Cancel Reply'; 'messageComposer.quotedMessagePreview.files.label_one': '{{ count }} file'; 'messageComposer.quotedMessagePreview.files.label_other': '{{ count }} files'; From 533e61075d57aadfff0e2c00f1f3de37f43abffa Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 28 Oct 2026 10:36:47 +0100 Subject: [PATCH 06/18] feat(Message): make the message bubble's content a configurable set MessageUI renders the bubble's content through MessageBubbleContent, which renders a messageBubbleContentSet modelled on the composer preview set: each item has a type, a Component and an isVisible rule read from the message. defaultMessageBubbleContentSet renders the quoted message, poll, attachments and text. Integrators install their own set through the new MessageBubbleContent component slot. A message that quotes and carries a poll now shows the quote first (v14 rendered the poll above it). The root element gets str-chat__message--has-poll, and a message with a poll no longer gets str-chat__message--has-single-attachment. Refs REACT-1441 --- ai-docs/ai-migration-v14-v15.md | 36 ++++ .../Message/MessageBubbleContent.defaults.tsx | 79 +++++++++ .../Message/MessageBubbleContent.tsx | 63 +++++++ src/components/Message/MessageUI.tsx | 85 ++++------ .../__tests__/MessageBubbleContent.test.tsx | 156 ++++++++++++++++++ src/components/Message/index.ts | 2 + src/context/ComponentContext.tsx | 5 + 7 files changed, 370 insertions(+), 56 deletions(-) create mode 100644 src/components/Message/MessageBubbleContent.defaults.tsx create mode 100644 src/components/Message/MessageBubbleContent.tsx create mode 100644 src/components/Message/__tests__/MessageBubbleContent.test.tsx diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index a687ab1ef1..394b0f287b 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1930,6 +1930,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 diff --git a/src/components/Message/MessageBubbleContent.defaults.tsx b/src/components/Message/MessageBubbleContent.defaults.tsx new file mode 100644 index 0000000000..7a23203ed0 --- /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 0000000000..6ae98f3687 --- /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 MessageBubbleContentState = { + /** 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: MessageBubbleContentState) => 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 702c9005d3..df2392acc6 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,55 +30,51 @@ 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'; type MessageUIWithContextProps = MessageContextValue; -const MessageUIWithContext = ({ - endOfGroup, - firstOfGroup, - groupedByUser, - handleAction, - highlighted, - isMessageAIGenerated, - isMyMessage, - message, - onUserClick, - onUserHover, - renderText, - showAvatar = 'incoming', -}: MessageUIWithContextProps) => { +const MessageUIWithContext = (props: MessageUIWithContextProps) => { + const { + endOfGroup, + firstOfGroup, + groupedByUser, + highlighted, + isMessageAIGenerated, + isMyMessage, + message, + onUserClick, + onUserHover, + showAvatar = 'incoming', + } = props; 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 +84,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 +102,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 +128,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 +219,11 @@ const MessageUIWithContext = ({ ) : ( <> - {poll && } - {message.quoted_message && } - {finalAttachments?.length ? ( - - ) : null} - {isAIGenerated ? ( - - ) : ( - - )} + {/* The bubble content reads `MessageContext`; this hands it the props passed to + `MessageUI`, which override the context's values. */} + + +
{hasReactions && } diff --git a/src/components/Message/__tests__/MessageBubbleContent.test.tsx b/src/components/Message/__tests__/MessageBubbleContent.test.tsx new file mode 100644 index 0000000000..978f29c6c6 --- /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/index.ts b/src/components/Message/index.ts index 479ddcc4be..c0c319b19d 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/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index d98e37badb..0eb40933ea 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -204,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. */ From b9244660b38100585d39e1463494a152c951f197 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 28 Oct 2026 10:39:35 +0100 Subject: [PATCH 07/18] chore(examples): order the message bubble's content in a new Message settings tab Settings -> Message lists defaultMessageBubbleContentSet (quoted message, poll, attachments, text) to drag or arrow into order. The vite app renders the result through the MessageBubbleContent slot. The drag-and-drop list is shared with Settings -> Composer as SortableSectionList. Refs REACT-1441 --- examples/vite/src/App.tsx | 2 + .../vite/src/AppSettings/AppSettings.scss | 16 +- examples/vite/src/AppSettings/AppSettings.tsx | 3 + examples/vite/src/AppSettings/state.ts | 18 ++- .../tabs/Composer/PreviewSectionsField.tsx | 151 ++++-------------- .../AppSettings/tabs/Message/MessageTab.tsx | 59 +++++++ .../src/AppSettings/tabs/Message/index.ts | 1 + .../AppSettings/tabs/SortableSectionList.tsx | 145 +++++++++++++++++ .../ConfigurableMessageBubbleContent.tsx | 13 ++ .../vite/src/MessageBubble/bubbleContent.ts | 29 ++++ 10 files changed, 308 insertions(+), 129 deletions(-) create mode 100644 examples/vite/src/AppSettings/tabs/Message/MessageTab.tsx create mode 100644 examples/vite/src/AppSettings/tabs/Message/index.ts create mode 100644 examples/vite/src/AppSettings/tabs/SortableSectionList.tsx create mode 100644 examples/vite/src/MessageBubble/ConfigurableMessageBubbleContent.tsx create mode 100644 examples/vite/src/MessageBubble/bubbleContent.ts diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index c09a0b5e1b..39415818ef 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -102,6 +102,7 @@ import { ConfigurableStartRecordingAudioButton, } from './ComposerPreviews/ConfigurableComposerPreviews.tsx'; import { ConfigurablePollCreationDialogControls } from './ComposerPreviews/ConfigurablePollCreationDialogControls.tsx'; +import { ConfigurableMessageBubbleContent } from './MessageBubble/ConfigurableMessageBubbleContent.tsx'; import { StreamDebugHandles } from './Debug'; import { installUploadHarness, @@ -702,6 +703,7 @@ const App = () => { HeaderEndContent: SidebarToggle, MessageActions: ConfigurableMessageActions, AttachmentSelector: CommandModeAttachmentSelector, + MessageBubbleContent: ConfigurableMessageBubbleContent, MessageComposerPreviews: ConfigurableComposerPreviews, PollCreationDialogControls: ConfigurablePollCreationDialogControls, StartRecordingAudioButton: ConfigurableStartRecordingAudioButton, diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index 4bb9efe61b..d5c2e399ca 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -1263,8 +1263,8 @@ padding: 10px 12px; } - // Settings -> Composer -> Preview sections: one draggable row per section, in render order. - .app__composer-preview-sections { + // 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); @@ -1273,7 +1273,7 @@ list-style: none; } - .app__composer-preview-sections__item { + .app__sortable-sections__item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--str-chat__spacing-sm); @@ -1293,29 +1293,29 @@ box-shadow: 0 0 0 1px var(--str-chat__accent-primary); } - &--disabled .app__composer-preview-sections__text { + &--disabled .app__sortable-sections__text { opacity: 0.55; } } - .app__composer-preview-sections__handle { + .app__sortable-sections__handle { display: flex; color: var(--str-chat__text-tertiary, var(--str-chat__text-secondary)); } - .app__composer-preview-sections__label { + .app__sortable-sections__label { font-size: 13px; font-weight: 600; color: var(--str-chat__text-primary); } - .app__composer-preview-sections__comment { + .app__sortable-sections__comment { color: var(--str-chat__text-secondary); font-size: 12px; line-height: 1.4; } - .app__composer-preview-sections__controls { + .app__sortable-sections__controls { display: flex; align-items: center; gap: 2px; diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx index dd9620cfa0..68892e07c3 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 38b5fcd50d..861e6a6d9f 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 { defaultMessageComposerPreviewSet, 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'; }; @@ -147,6 +159,7 @@ export type AppSettingsState = { language: LanguageSettingsState; layout: LayoutSettingsState; messageActions: MessageActionsSettingsState; + messageBubble: MessageBubbleSettingsState; messageList: MessageListSettingsState; notifications: NotificationsSettingsState; panelLayout: PanelLayoutSettingsState; @@ -230,6 +243,9 @@ const defaultAppSettingsState: AppSettingsState = { }, }, }, + messageBubble: { + contentOrder: defaultMessageBubbleContentSet.map(({ type }) => type), + }, messageList: { type: 'standard', }, diff --git a/examples/vite/src/AppSettings/tabs/Composer/PreviewSectionsField.tsx b/examples/vite/src/AppSettings/tabs/Composer/PreviewSectionsField.tsx index b9aca4d450..e88cfa1851 100644 --- a/examples/vite/src/AppSettings/tabs/Composer/PreviewSectionsField.tsx +++ b/examples/vite/src/AppSettings/tabs/Composer/PreviewSectionsField.tsx @@ -1,137 +1,48 @@ -import clsx from 'clsx'; -import { useState } from 'react'; -import { - Button, - type DefaultMessageComposerPreviewType, - IconArrowDown, - IconArrowUp, - IconReorder, - SwitchField, -} from 'stream-chat-react'; +import type { DefaultMessageComposerPreviewType } from 'stream-chat-react'; import { PREVIEW_SECTION_INFO, resolvePreviewSectionOrder, } from '../../../ComposerPreviews/previewSections'; import { appSettingsStore, useAppSettingsState } from '../../state'; +import { SortableSectionList } from '../SortableSectionList'; -const moveItem = (order: string[], from: number, to: number) => { - const next = [...order]; - const [moved] = next.splice(from, 1); - next.splice(to, 0, moved); - return next; -}; - -/** - * The composer's preview sections as a list to drag into order and switch on or off. Up and down - * buttons do the same from the keyboard. - */ +/** The composer's preview sections, to drag into order and switch on or off. */ export const PreviewSectionsField = () => { const { composer } = useAppSettingsState(); - const order = resolvePreviewSectionOrder(composer.previewSectionOrder); const { disabledPreviewSections } = composer; - const [draggedType, setDraggedType] = useState(null); - const [dropIndex, setDropIndex] = useState(null); - - const setOrder = (previewSectionOrder: string[]) => - appSettingsStore.partialNext({ composer: { ...composer, previewSectionOrder } }); - - const setEnabled = (type: string, enabled: boolean) => - appSettingsStore.partialNext({ - composer: { - ...composer, - disabledPreviewSections: enabled - ? disabledPreviewSections.filter((disabled) => disabled !== type) - : [...disabledPreviewSections, type], - }, - }); - - const endDrag = () => { - setDraggedType(null); - setDropIndex(null); - }; return ( -
    { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - setDropIndex(null); - } - }} - > - {order.map((type, index) => { + + 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 DefaultMessageComposerPreviewType]; - const enabled = !disabledPreviewSections.includes(type); - - return ( -
  1. { - 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) - setOrder(moveItem(order, order.indexOf(draggedType), index)); - endDrag(); - }} - > - - - -
    -
    {label}
    -
    - {type}. Removing it also removes: {removes} -
    -
    -
    - - - setEnabled(type, event.target.checked)} - /> -
    -
  2. - ); + 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 0000000000..645e32d98c --- /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 0000000000..05027c4501 --- /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 0000000000..29f13c0791 --- /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 ( +
  1. { + 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}
    + )} +
    +
    + + + {onEnabledChange && ( + onEnabledChange(type, event.target.checked)} + /> + )} +
    +
  2. + ); + })} +
+ ); +}; diff --git a/examples/vite/src/MessageBubble/ConfigurableMessageBubbleContent.tsx b/examples/vite/src/MessageBubble/ConfigurableMessageBubbleContent.tsx new file mode 100644 index 0000000000..5f72fe2e5d --- /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 0000000000..f5f0f676fd --- /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), + ); From c46af0f3d8262271b213320e58c04c54922fb806 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 30 Oct 2026 06:57:45 +0100 Subject: [PATCH 08/18] chore(examples): send the attached poll with the message's text and attachments While Settings -> Composer shows the Poll preview, the composer setup removes stream-chat's poll-only composition step, so the poll the dialog attaches goes out with the message instead of as a message of its own. Turning the section off puts the step back in its place. The migration guide's "Attach poll" example shows the same. Refs REACT-1441 --- ai-docs/ai-migration-v14-v15.md | 14 +++++++++++++- examples/vite/src/App.tsx | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 394b0f287b..b1e71b4bb0 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2214,7 +2214,19 @@ export const App = ({ children }: { children: React.ReactNode }) => ( - `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`, `GeolocationPreview` and `PollPreview` are now exported from the root. -The default poll creation dialog sends the poll as soon as it is created, so the poll preview only shows a poll created elsewhere. To have the dialog attach the poll to the message being composed instead, replace its footer through the new `PollCreationDialogControls` slot (`PollCreationDialogControls` and `PollCreationDialogControlsProps` are exported) and call `createPoll()` without `send()`: +The default poll creation dialog sends the poll as soon as it is created, so the poll preview only shows a poll created elsewhere. 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 { diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 39415818ef..c26f57962e 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -21,9 +21,11 @@ import { createCommandInjectionMiddleware, createCommandStringExtractionMiddleware, createDraftCommandInjectionMiddleware, + createPollOnlyCompositionMiddleware, createPriorityOwnershipResolver, isVoiceRecordingAttachment, MessageSearchSource, + POLL_ONLY_COMPOSITION_MIDDLEWARE_ID, SearchController, UserSearchSource, } from 'stream-chat'; @@ -600,6 +602,24 @@ const App = () => { 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, From 31a6a18a452e936653e9a43c2e969eaa60b12474 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 30 Oct 2026 14:35:50 +0100 Subject: [PATCH 09/18] feat(MessageActions): let each message action carry its own visibility rule Message action set items take an optional isVisible, called with MessageActionVisibilityFilterParams ({ isMyMessage, isThreadReply, message }). The default set's rules about the message move from useBaseMessageActionSetFilter onto their items: edit hides for a poll message or a giphy, copyMessageText for a message without text, download for one without a downloadable attachment. Replacing an item changes its rule. isVisible can only hide an action: the base filter still checks capabilities, server-enabled features and the message's status. Item rules also apply when disableBaseMessageActionSetFilter is set. useUserRole().canEdit is now the update permission alone, the same check the base filter makes. The composer preview and bubble content isVisible arguments are renamed to match: MessageComposerPreviewVisibilityFilterParams and MessageBubbleContentVisibilityFilterParams. The vite example allows editing poll messages while the Poll preview is shown. Refs REACT-1441 --- ai-docs/ai-migration-v14-v15.md | 14 ++- .../ConfigurableMessageActions.tsx | 19 +++- .../Message/MessageBubbleContent.tsx | 4 +- .../hooks/__tests__/useUserRole.test.tsx | 12 ++ src/components/Message/hooks/useUserRole.ts | 8 +- .../MessageActions.defaults.tsx | 9 ++ .../MessageActions/MessageActions.tsx | 15 +++ .../__tests__/MessageActions.test.tsx | 90 ++++++++++++++- .../hooks/useBaseMessageActionSetFilter.ts | 54 +++++---- .../MessageComposerPreviews.tsx | 4 +- .../hooks/useMessageComposerPreviewState.ts | 107 +++++++++--------- 11 files changed, 244 insertions(+), 92 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index b1e71b4bb0..0d1bf658ca 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1673,6 +1673,18 @@ 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`. For example, to let users edit the text and attachments sent with a poll: + + ```tsx + const messageActionSet = defaultMessageActionSet.map((item) => + '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 && 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: @@ -2170,7 +2182,7 @@ export const ComposerPreviews = ({ children }: { children: ReactNode }) => ( ### New: composer previews are a configurable set, with a poll preview -Not a break. 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` (`MessageComposerPreviewState`) carries the composer's `attachments`, `customData`, `editedMessage`, `linkPreviews` (loading or loaded), `location`, `pollId` and `quotedMessage`. The preview area renders when any item is visible. +Not a break. 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`) and adds `poll`. The poll preview shows while `messageComposer.state.pollId` holds a poll created with `messageComposer.createPoll()` and not sent yet, and not while editing. It shows the poll's question and options (fetching a poll missing from `client.polls`, e.g. one restored from a draft), and its remove button detaches the poll from the composer. Replace it through the `PollPreview` component slot. diff --git a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx index efc5d5433a..eaf2ba282b 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, @@ -374,9 +375,24 @@ export const ConfigurableMessageActions = ( // 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 @@ -459,6 +475,7 @@ export const ConfigurableMessageActions = ( }, [ customDeleteEnabled, editDisabled, + pollDisabled, quoteDisabled, surfaceActions.disabledActionTypes, surfaceActions.inlineEdit, diff --git a/src/components/Message/MessageBubbleContent.tsx b/src/components/Message/MessageBubbleContent.tsx index 6ae98f3687..84f14f2bd4 100644 --- a/src/components/Message/MessageBubbleContent.tsx +++ b/src/components/Message/MessageBubbleContent.tsx @@ -15,7 +15,7 @@ export type DefaultMessageBubbleContentType = | 'text'; /** What decides which parts of a message bubble show. */ -export type MessageBubbleContentState = { +export type MessageBubbleContentVisibilityFilterParams = { /** Whether `isMessageAIGenerated` marks the message as AI generated (streamed text). */ isAIGenerated: boolean; message: LocalMessage; @@ -25,7 +25,7 @@ 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: MessageBubbleContentState) => boolean; + isVisible: (state: MessageBubbleContentVisibilityFilterParams) => boolean; /** Names the content; unique within a set. */ type: DefaultMessageBubbleContentType | (string & {}); }; diff --git a/src/components/Message/hooks/__tests__/useUserRole.test.tsx b/src/components/Message/hooks/__tests__/useUserRole.test.tsx index d773b548e9..7456d21c51 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 9c07f9b123..19b28fcb6d 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/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx index 58c3e6e335..8ed5fe4e90 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 && message.command !== 'giphy', placement: 'dropdown', type: 'edit', }, diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index 5a0b193345..d22a23f110 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 c6a00bf37d..637b45ccb3 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,94 @@ 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('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 = () => ; diff --git a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts index aba4d201fb..4f15fecc40 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/MessageComposerPreviews.tsx b/src/components/MessageComposer/MessageComposerPreviews.tsx index 9d96b23189..167530cf3d 100644 --- a/src/components/MessageComposer/MessageComposerPreviews.tsx +++ b/src/components/MessageComposer/MessageComposerPreviews.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useMessageComposerPreviewSetFilter } from './hooks/useMessageComposerPreviewSetFilter'; -import type { MessageComposerPreviewState } from './hooks/useMessageComposerPreviewState'; +import type { MessageComposerPreviewVisibilityFilterParams } from './hooks/useMessageComposerPreviewState'; import { defaultMessageComposerPreviewSet } from './MessageComposerPreviews.defaults'; /** @@ -21,7 +21,7 @@ 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: MessageComposerPreviewState) => boolean; + isVisible: (state: MessageComposerPreviewVisibilityFilterParams) => boolean; /** Names the preview; unique within a set. */ type: DefaultMessageComposerPreviewType | (string & {}); }; diff --git a/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts b/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts index 336488d6b9..15554133e1 100644 --- a/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts +++ b/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts @@ -42,7 +42,7 @@ const customDataManagerStateSelector = (state: CustomDataManagerState) => ({ * The composer state that decides which previews show. Each item of a * `messageComposerPreviewSet` reads it in `isVisible`. */ -export type MessageComposerPreviewState = { +export type MessageComposerPreviewVisibilityFilterParams = { attachments: AttachmentManagerState['attachments']; /** The composer's custom data (`messageComposer.customDataManager`). */ customData: CustomDataManagerState['custom']; @@ -59,57 +59,58 @@ export type MessageComposerPreviewState = { * Subscribes to the composer state that decides which previews show, for the composer * `useMessageComposerController()` resolves. */ -export const useMessageComposerPreviewState = (): MessageComposerPreviewState => { - 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, - ); +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], - ); + 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, - ], - ); -}; + return useMemo( + () => ({ + attachments, + customData, + editedMessage, + linkPreviews, + location, + pollId, + quotedMessage, + }), + [ + attachments, + customData, + editedMessage, + linkPreviews, + location, + pollId, + quotedMessage, + ], + ); + }; From 6c2435a47162e28c309f06987b511664fada9f70 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 30 Oct 2026 15:19:34 +0100 Subject: [PATCH 10/18] fix(MessageComposer): drop the edit preview's nested previews wrapper The edited-message preview item wrapped its preview in a second str-chat__message-composer-previews element, so the edit preview got the area's padding twice and sat further in than the quoted-message preview. Also types the migration guide's message action snippet so it compiles under noImplicitAny. Refs REACT-1441 --- ai-docs/ai-migration-v14-v15.md | 11 +++++++---- .../MessageComposerPreviews.defaults.tsx | 14 ++++++-------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 0d1bf658ca..5101237c94 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1676,10 +1676,13 @@ export const List = () => ( - 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`. For example, to let users edit the text and attachments sent with a poll: ```tsx - const messageActionSet = defaultMessageActionSet.map((item) => - 'type' in item && item.type === 'edit' - ? { ...item, isVisible: ({ message }) => message.command !== 'giphy' } - : item, + 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, ); ``` diff --git a/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx index f8214cbe9f..9015770d50 100644 --- a/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx +++ b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx @@ -37,14 +37,12 @@ const DefaultMessageComposerPreviewComponents = { if (!editedMessage) return null; return ( -
- { - restorePreEditSnapshot(messageComposer); - }} - /> -
+ { + restorePreEditSnapshot(messageComposer); + }} + /> ); }, QuotedMessage() { From 30b80ed70e9d0ba4ae09e8e451b89074f144ef12 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 10:17:02 +0100 Subject: [PATCH 11/18] chore(deps): upgrate stream-chat to v10.0.0-rc.28 --- examples/ai-chatbot/package.json | 2 +- examples/tutorial/package.json | 2 +- examples/vite/package.json | 2 +- package.json | 4 +-- yarn.lock | 43 +++++++++++++++++++++----------- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/examples/ai-chatbot/package.json b/examples/ai-chatbot/package.json index 631e937da9..6bbd38fe2f 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.25", + "stream-chat": "10.0.0-rc.28", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index c38a10c80e..e681628323 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.25", + "stream-chat": "10.0.0-rc.28", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/package.json b/examples/vite/package.json index 5e15c9a37c..469b0154a1 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.25", + "stream-chat": "10.0.0-rc.28", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/package.json b/package.json index 15e9cc119f..749b698847 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.25" + "stream-chat": "^10.0.0-rc.28" }, "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.25", + "stream-chat": "10.0.0-rc.28", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.1.3", diff --git a/yarn.lock b/yarn.lock index 9b9a40cdbe..dffdda57dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2149,10 +2149,10 @@ __metadata: languageName: node linkType: hard -"@stream-io/logger@npm:^2.0.0": - version: 2.0.0 - resolution: "@stream-io/logger@npm:2.0.0" - checksum: 10c0/b12b1a8005b99b7fbdcfd91dbda08b5021da9044f861bb574005975d43dcfe9f406795015c6ddadc3a18cb9dda0c949e303b6c454b9f5139fa24701bed5a0bd7 +"@stream-io/logger@npm:^2.1.0": + version: 2.1.0 + resolution: "@stream-io/logger@npm:2.1.0" + checksum: 10c0/0a09c9420b8bbec7a3007bf02acccaf7c0792b18d76eb791198e2a20890f1244b42788c3cc99d6ab52a591f49fde80dd6b2236e194f95f03b36fce5d0aa71dc3 languageName: node linkType: hard @@ -2171,6 +2171,21 @@ __metadata: languageName: node linkType: hard +"@stream-io/state-store@npm:^1.2.0": + version: 1.2.0 + resolution: "@stream-io/state-store@npm:1.2.0" + peerDependencies: + react: ^17 || ^18 || ^19 + use-sync-external-store: ^1.5.0 + peerDependenciesMeta: + react: + optional: true + use-sync-external-store: + optional: true + checksum: 10c0/27179805cb9a042416c741e1759c3324eefae0b27612c50f5fa0e0e13675d71bbe0a324978fcb5fdceaa4df5a99d11c6746eae319761216861b338e8cb7f1928 + languageName: node + linkType: hard + "@stream-io/stream-chat-react-ai-chatbot@workspace:examples/ai-chatbot": version: 0.0.0-use.local resolution: "@stream-io/stream-chat-react-ai-chatbot@workspace:examples/ai-chatbot" @@ -2186,7 +2201,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.25" + stream-chat: "npm:10.0.0-rc.28" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" languageName: unknown @@ -2203,7 +2218,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.25" + stream-chat: "npm:10.0.0-rc.28" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -2231,7 +2246,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.25" + stream-chat: "npm:10.0.0-rc.28" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -10257,7 +10272,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.25" + stream-chat: "npm:10.0.0-rc.28" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" @@ -10299,12 +10314,12 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:10.0.0-rc.25": - version: 10.0.0-rc.25 - resolution: "stream-chat@npm:10.0.0-rc.25" +"stream-chat@npm:10.0.0-rc.28": + version: 10.0.0-rc.28 + resolution: "stream-chat@npm:10.0.0-rc.28" dependencies: - "@stream-io/logger": "npm:^2.0.0" - "@stream-io/state-store": "npm:^1.1.6" + "@stream-io/logger": "npm:^2.1.0" + "@stream-io/state-store": "npm:^1.2.0" axios: "npm:^1.19.0" linkifyjs: "npm:^4.3.3" dependenciesMeta: @@ -10312,7 +10327,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/cb8a316988d73c00347c9a98047246ac05cbedcba28560afaa6358b6e022b84350a51bb80a73bf3376ff68a91fffe2d203414831b342200cde831972ea027d11 + checksum: 10c0/0132ae268d1b228eeb8b70a08b03eff77e955f8a2215fcf7804fa4df8dd7c032adc3a356d934cbf883fd4835f823612ec9d53ccf3948c494023ae5d636341f01 languageName: node linkType: hard From 7ce763722b0093cb2d2fb492fec7586c82462232 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 10:40:31 +0100 Subject: [PATCH 12/18] fix(Message): address review findings on bubble context, poll edit rule and poll preview Read the live MessageContext in the bubble content, so a translation toggle the memo comparator skips still updates the text. Props passed to MessageUI are provided outside the memo. Key the edit action's rule on poll_id, which a failed local poll message carries. Delete the unsent poll when its preview is removed, and detach a poll that no longer exists. Document the custom preview slot gating and the edit rule on failed messages. --- ai-docs/ai-migration-v14-v15.md | 22 ++++++-- src/components/Message/MessageUI.tsx | 41 ++++++++------- .../Message/__tests__/MessageText.test.tsx | 52 +++++++++++++++++++ .../MessageActions.defaults.tsx | 2 +- .../__tests__/MessageActions.test.tsx | 19 +++++++ .../MessageComposer/PollPreview.tsx | 28 ++++++++-- .../MessageComposerPreviews.test.tsx | 34 +++++++++++- 7 files changed, 169 insertions(+), 29 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 5101237c94..f732dcea93 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1673,7 +1673,7 @@ 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`. For example, to let users edit the text and attachments sent with a poll: +- 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'; @@ -1687,7 +1687,7 @@ export const List = () => ( ``` - 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 && message.command !== 'giphy'` itself. +- `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: @@ -2185,9 +2185,9 @@ export const ComposerPreviews = ({ children }: { children: ReactNode }) => ( ### New: composer previews are a configurable set, with a poll preview -Not a break. 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. +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`) and adds `poll`. The poll preview shows while `messageComposer.state.pollId` holds a poll created with `messageComposer.createPoll()` and not sent yet, and not while editing. It shows the poll's question and options (fetching a poll missing from `client.polls`, e.g. one restored from a draft), and its remove button detaches the poll from the composer. Replace it through the `PollPreview` component slot. +`defaultMessageComposerPreviewSet` keeps v14's previews in v14's order (`editedMessage`, `quotedMessage`, `voiceRecording`, `attachments`, `linkPreviews`, `location`) and adds `poll`. The poll preview shows while `messageComposer.state.pollId` holds a poll created with `messageComposer.createPoll()` and not sent yet, and not while editing. It shows the poll's question and options (fetching a poll missing from `client.polls`, e.g. one restored from a draft), and its remove button detaches the poll from the composer and deletes it, since it was never sent. A poll that no longer exists is detached, so the send does not fail on it. Replace it through the `PollPreview` component slot. Filter, reorder or extend the default set, then install it with the `MessageComposerPreviews` slot: @@ -2228,6 +2228,20 @@ export const App = ({ children }: { children: React.ReactNode }) => ( - `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`, `GeolocationPreview` and `PollPreview` 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: + + ```tsx + const previewSet = defaultMessageComposerPreviewSet.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, so the poll preview only shows a poll created elsewhere. To have the dialog attach the poll to the message being composed instead: diff --git a/src/components/Message/MessageUI.tsx b/src/components/Message/MessageUI.tsx index df2392acc6..058386a2a9 100644 --- a/src/components/Message/MessageUI.tsx +++ b/src/components/Message/MessageUI.tsx @@ -48,19 +48,18 @@ import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils type MessageUIWithContextProps = MessageContextValue; -const MessageUIWithContext = (props: MessageUIWithContextProps) => { - const { - endOfGroup, - firstOfGroup, - groupedByUser, - highlighted, - isMessageAIGenerated, - isMyMessage, - message, - onUserClick, - onUserHover, - showAvatar = 'incoming', - } = props; +const MessageUIWithContext = ({ + endOfGroup, + firstOfGroup, + groupedByUser, + highlighted, + isMessageAIGenerated, + isMyMessage, + message, + onUserClick, + onUserHover, + showAvatar = 'incoming', +}: MessageUIWithContextProps) => { const channel = useChannel(); const threadInstance = useThreadContext(); const { t } = useTranslationContext(); @@ -219,11 +218,7 @@ const MessageUIWithContext = (props: MessageUIWithContextProps) => { ) : ( <> - {/* The bubble content reads `MessageContext`; this hands it the props passed to - `MessageUI`, which override the context's values. */} - - - +
{hasReactions && } @@ -259,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 = ; + + // 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 ; + return ( + {messageUI} + ); }; diff --git a/src/components/Message/__tests__/MessageText.test.tsx b/src/components/Message/__tests__/MessageText.test.tsx index 050dd79945..94f856eb81 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 ( + + ); + }; + + 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/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx index 8ed5fe4e90..9908d9abeb 100644 --- a/src/components/MessageActions/MessageActions.defaults.tsx +++ b/src/components/MessageActions/MessageActions.defaults.tsx @@ -854,7 +854,7 @@ 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 && message.command !== 'giphy', + isVisible: ({ message }) => !message.poll_id && message.command !== 'giphy', placement: 'dropdown', type: 'edit', }, diff --git a/src/components/MessageActions/__tests__/MessageActions.test.tsx b/src/components/MessageActions/__tests__/MessageActions.test.tsx index 637b45ccb3..93a4e596a2 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.tsx +++ b/src/components/MessageActions/__tests__/MessageActions.test.tsx @@ -1237,6 +1237,25 @@ describe('', () => { 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, diff --git a/src/components/MessageComposer/PollPreview.tsx b/src/components/MessageComposer/PollPreview.tsx index 0e893791f8..02b2f52fef 100644 --- a/src/components/MessageComposer/PollPreview.tsx +++ b/src/components/MessageComposer/PollPreview.tsx @@ -27,6 +27,9 @@ const pollStateSelector = ({ 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; @@ -142,16 +145,33 @@ export const PollPreview = () => { .then((poll) => { if (!cancelled) setFetchedPoll(poll); }) - .catch(() => { - // The preview keeps its placeholder; the poll still goes out with the message. + .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, pollId]); + }, [client, messageComposer, pollId]); if (!pollId) return null; + const removePoll = () => { + messageComposer.state.partialNext({ pollId: null }); + // The poll was created for this message only and was never sent, so nothing else uses it. + client.deletePoll({ poll_id: pollId }).catch(() => { + // An orphaned poll is harmless; the user has nothing to retry. + }); + }; + return (
@@ -168,7 +188,7 @@ export const PollPreview = () => { aria-label={t('messageComposer.pollPreview.removePoll.ariaLabel', 'Remove poll')} className='str-chat__attachment-preview__remove-button' data-testid='poll-preview-delete-button' - onClick={() => messageComposer.state.partialNext({ pollId: null })} + onClick={removePoll} />
); diff --git a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx index e0aa659599..a52385f55d 100644 --- a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx @@ -283,9 +283,40 @@ describe('MessageComposerPreviews', () => { expect(await screen.findByText('Fetched question')).toBeInTheDocument(); }); - it('detaches the poll from the composer on remove', async () => { + it('detaches a poll that no longer exists', async () => { + const { channel, client } = await setup(); + vi.spyOn(client, 'getPoll').mockRejectedValue( + Object.assign(new Error('poll not found'), { code: 16 }), + ); + const { container } = await renderPreviews({ channel, client }); + + await act(() => { + channel.messageComposer.state.partialNext({ pollId: 'deleted-poll' }); + }); + + expect(channel.messageComposer.pollId).toBeNull(); + expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument(); + }); + + it('keeps the poll when fetching it fails for another reason', async () => { + const { channel, client } = await setup(); + vi.spyOn(client, 'getPoll').mockRejectedValue(new Error('Network Error')); + await renderPreviews({ channel, client }); + + await act(() => { + channel.messageComposer.state.partialNext({ pollId: 'unreachable-poll' }); + }); + + expect(channel.messageComposer.pollId).toBe('unreachable-poll'); + expect(screen.getByTestId('poll-preview')).toHaveTextContent('Poll'); + }); + + it('detaches the poll from the composer and deletes it on remove', async () => { const { channel, client } = await setup(); const poll = cachePoll(client); + const deletePollSpy = vi + .spyOn(client, 'deletePoll') + .mockResolvedValue(fromPartial({})); const { container } = await renderPreviews({ channel, client }); await act(() => { @@ -297,6 +328,7 @@ describe('MessageComposerPreviews', () => { expect(channel.messageComposer.pollId).toBeNull(); expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument(); + expect(deletePollSpy).toHaveBeenCalledWith({ poll_id: poll.id }); }); it('has no accessibility violations', async () => { From 7962e1b345d981cfd0a24a6ff469a99ace83a5fd Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 10:45:46 +0100 Subject: [PATCH 13/18] chore(deps): upgrate stream-chat to v10.0.0-rc.29 --- examples/ai-chatbot/package.json | 2 +- examples/tutorial/package.json | 2 +- examples/vite/package.json | 2 +- package.json | 4 ++-- yarn.lock | 18 +++++++++--------- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/ai-chatbot/package.json b/examples/ai-chatbot/package.json index 6bbd38fe2f..64722073f5 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/tutorial/package.json b/examples/tutorial/package.json index e681628323..dcc98341ad 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 469b0154a1..5fbd361844 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/package.json b/package.json index 749b698847..d0ee2d9dc5 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/yarn.lock b/yarn.lock index dffdda57dd..2217f4f16b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2201,7 +2201,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 @@ -2218,7 +2218,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" @@ -2246,7 +2246,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" @@ -10272,7 +10272,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" @@ -10290,7 +10290,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.25 + stream-chat: ^10.0.0-rc.28 dependenciesMeta: "@parcel/watcher": built: true @@ -10314,9 +10314,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" @@ -10327,7 +10327,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/0132ae268d1b228eeb8b70a08b03eff77e955f8a2215fcf7804fa4df8dd7c032adc3a356d934cbf883fd4835f823612ec9d53ccf3948c494023ae5d636341f01 + checksum: 10c0/aef5fcb8c3ee4c93f1fb4bcd5d939dc6e9263ac14178dce632671bdce7993fb8e766076221975756cc7956bcdd797278c2bff7ead4fcc7046dc449130de10f13 languageName: node linkType: hard From 82a78fa18a534de80e8ddc4ae2c4ef739aceb797 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 10:59:16 +0100 Subject: [PATCH 14/18] fix(MessageComposer): detach the poll on remove without deleting it The composer cannot tell whether it created the poll or whether the poll was sent (a poll_id restored from a draft), so deleting it could remove a published poll and its votes. Also drop the default location item in the migration guide's custom AttachmentPreviewList example, so the location is not shown twice. --- ai-docs/ai-migration-v14-v15.md | 27 ++++++++++--------- .../MessageComposer/PollPreview.tsx | 14 ++++------ .../MessageComposerPreviews.test.tsx | 8 +++--- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index f732dcea93..7f123edcca 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2187,7 +2187,7 @@ export const ComposerPreviews = ({ children }: { children: ReactNode }) => ( 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`) and adds `poll`. The poll preview shows while `messageComposer.state.pollId` holds a poll created with `messageComposer.createPoll()` and not sent yet, and not while editing. It shows the poll's question and options (fetching a poll missing from `client.polls`, e.g. one restored from a draft), and its remove button detaches the poll from the composer and deletes it, since it was never sent. A poll that no longer exists is detached, so the send does not fail on it. Replace it through the `PollPreview` component slot. +`defaultMessageComposerPreviewSet` keeps v14's previews in v14's order (`editedMessage`, `quotedMessage`, `voiceRecording`, `attachments`, `linkPreviews`, `location`) and adds `poll`. The poll preview shows while `messageComposer.state.pollId` holds a poll created with `messageComposer.createPoll()` and not sent yet, and not while editing. It shows the poll's question and options (fetching a poll missing from `client.polls`, e.g. one restored from a draft), and its remove button detaches the poll from the composer. It does not delete the poll: the composer cannot tell whether it created it or whether it was sent, e.g. a `poll_id` restored from a draft. A poll that no longer exists is detached, so the send does not fail on it. Replace it through the `PollPreview` component slot. Filter, reorder or extend the default set, then install it with the `MessageComposerPreviews` slot: @@ -2228,19 +2228,22 @@ export const App = ({ children }: { children: React.ReactNode }) => ( - `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`, `GeolocationPreview` and `PollPreview` 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: +- **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 - const previewSet = defaultMessageComposerPreviewSet.map( - (item): MessageComposerPreviewSetItem => - item.type === 'attachments' - ? { - ...item, - isVisible: ({ attachments, location }) => - attachments.length > 0 || !!location, - } - : item, - ); + // 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, so the poll preview only shows a poll created elsewhere. To have the dialog attach the poll to the message being composed instead: diff --git a/src/components/MessageComposer/PollPreview.tsx b/src/components/MessageComposer/PollPreview.tsx index 02b2f52fef..d484280aae 100644 --- a/src/components/MessageComposer/PollPreview.tsx +++ b/src/components/MessageComposer/PollPreview.tsx @@ -164,14 +164,6 @@ export const PollPreview = () => { if (!pollId) return null; - const removePoll = () => { - messageComposer.state.partialNext({ pollId: null }); - // The poll was created for this message only and was never sent, so nothing else uses it. - client.deletePoll({ poll_id: pollId }).catch(() => { - // An orphaned poll is harmless; the user has nothing to retry. - }); - }; - return (
@@ -188,7 +180,11 @@ export const PollPreview = () => { aria-label={t('messageComposer.pollPreview.removePoll.ariaLabel', 'Remove poll')} className='str-chat__attachment-preview__remove-button' data-testid='poll-preview-delete-button' - onClick={removePoll} + onClick={() => + // 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 }) + } />
); diff --git a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx index a52385f55d..c0d79e3c5d 100644 --- a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx @@ -311,12 +311,10 @@ describe('MessageComposerPreviews', () => { expect(screen.getByTestId('poll-preview')).toHaveTextContent('Poll'); }); - it('detaches the poll from the composer and deletes it on remove', async () => { + it('detaches the poll from the composer on remove without deleting it', async () => { const { channel, client } = await setup(); const poll = cachePoll(client); - const deletePollSpy = vi - .spyOn(client, 'deletePoll') - .mockResolvedValue(fromPartial({})); + const deletePollSpy = vi.spyOn(client, 'deletePoll'); const { container } = await renderPreviews({ channel, client }); await act(() => { @@ -328,7 +326,7 @@ describe('MessageComposerPreviews', () => { expect(channel.messageComposer.pollId).toBeNull(); expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument(); - expect(deletePollSpy).toHaveBeenCalledWith({ poll_id: poll.id }); + expect(deletePollSpy).not.toHaveBeenCalled(); }); it('has no accessibility violations', async () => { From 6f254c7f68d0f5a5b27ea798fa05628373cf6c1c Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 11:13:16 +0100 Subject: [PATCH 15/18] chore(deps): bump @stream-io/state-store to 1.2.0 to match stream-chat rc.29 stream-chat 10.0.0-rc.29 requires ^1.2.0, so the lockfile held two copies and the store types no longer matched. --- package.json | 2 +- yarn.lock | 21 +++------------------ 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index d0ee2d9dc5..59c08453bb 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "@floating-ui/react": "^0.27.19", "@react-aria/focus": "^3.22.0", "@stream-io/i18n": "^1.0.2", - "@stream-io/state-store": "^1.1.6", + "@stream-io/state-store": "^1.2.0", "chart.js": "^4.5.1", "clsx": "^2.1.1", "emoji-regex": "^9.2.2", diff --git a/yarn.lock b/yarn.lock index 2217f4f16b..8adca9b5f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2156,22 +2156,7 @@ __metadata: languageName: node linkType: hard -"@stream-io/state-store@npm:^1.1.6": - version: 1.1.6 - resolution: "@stream-io/state-store@npm:1.1.6" - peerDependencies: - react: ^17 || ^18 || ^19 - use-sync-external-store: ^1.5.0 - peerDependenciesMeta: - react: - optional: true - use-sync-external-store: - optional: true - checksum: 10c0/42927cdb6a5bf6ba5f4ec11bb5e530db0a04edc65abd5348eb423780e40a3e53f57125e328d0318a0dc6b24ca0fcb28e8b91895a14a62ed8d82645f67ebcee5a - languageName: node - linkType: hard - -"@stream-io/state-store@npm:^1.2.0": +"@stream-io/state-store@npm:^1.1.6, @stream-io/state-store@npm:^1.2.0": version: 1.2.0 resolution: "@stream-io/state-store@npm:1.2.0" peerDependencies: @@ -10212,7 +10197,7 @@ __metadata: "@semantic-release/git": "npm:^10.0.1" "@semantic-release/github": "npm:^12.0.8" "@stream-io/i18n": "npm:^1.0.2" - "@stream-io/state-store": "npm:^1.1.6" + "@stream-io/state-store": "npm:^1.2.0" "@stream-io/transliterate": "npm:^1.5.5" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.9.1" @@ -10290,7 +10275,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 From 6ba38abb80332942fcd9dc55fde65314dacaf068 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 14:31:37 +0100 Subject: [PATCH 16/18] refactor(MessageComposer)!: move the poll preview to the vite example The other SDKs ship no composer poll preview, so the React SDK does not either. The LLC still sends a poll with the message's text and attachments once the poll-only middleware is removed; showing that poll in the composer is the app's own preview item. The example's PollPreview fetches a restored draft's poll itself: a draft carries only `poll_id`, and `client.polls` is not reactive yet. BREAKING CHANGE: `PollPreview` is no longer exported, the `PollPreview` component slot is removed, and `defaultMessageComposerPreviewSet` has no `poll` item. An app that attaches polls to the message adds its own preview item (see ai-docs/ai-migration-v14-v15.md). --- ai-docs/ai-migration-v14-v15.md | 56 +++++- .../AppSettings/tabs/Composer/ComposerTab.tsx | 9 +- .../tabs/Composer/PreviewSectionsField.tsx | 6 +- .../src/ComposerPreviews/PollPreview.scss | 87 +++++++++ .../src/ComposerPreviews}/PollPreview.tsx | 94 +++++----- .../src/ComposerPreviews/previewSections.ts | 26 +-- examples/vite/src/i18n/de.ts | 14 -- examples/vite/src/i18n/it.ts | 14 -- examples/vite/src/index.scss | 1 + .../MessageComposerPreviews.defaults.tsx | 11 -- .../MessageComposerPreviews.tsx | 3 +- .../MessageComposerPreviews.test.tsx | 165 ++++-------------- .../hooks/useMessageComposerPreviewState.ts | 5 +- src/components/MessageComposer/index.ts | 1 - .../styling/AttachmentPreviewThumbnail.scss | 1 - .../MessageComposer/styling/PollPreview.scss | 80 --------- .../MessageComposer/styling/index.scss | 1 - src/context/ComponentContext.tsx | 2 - src/i18n/__tests__/catalog.fixture.json | 12 -- src/i18n/keys.ts | 12 -- 20 files changed, 255 insertions(+), 345 deletions(-) create mode 100644 examples/vite/src/ComposerPreviews/PollPreview.scss rename {src/components/MessageComposer => examples/vite/src/ComposerPreviews}/PollPreview.tsx (64%) delete mode 100644 src/components/MessageComposer/styling/PollPreview.scss diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 7f123edcca..9ddc5aa171 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2183,11 +2183,11 @@ export const ComposerPreviews = ({ children }: { children: ReactNode }) => ( ); ``` -### New: composer previews are a configurable set, with a poll preview +### 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`) and adds `poll`. The poll preview shows while `messageComposer.state.pollId` holds a poll created with `messageComposer.createPoll()` and not sent yet, and not while editing. It shows the poll's question and options (fetching a poll missing from `client.polls`, e.g. one restored from a draft), and its remove button detaches the poll from the composer. It does not delete the poll: the composer cannot tell whether it created it or whether it was sent, e.g. a `poll_id` restored from a draft. A poll that no longer exists is detached, so the send does not fail on it. Replace it through the `PollPreview` component slot. +`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: @@ -2227,7 +2227,7 @@ export const App = ({ children }: { children: React.ReactNode }) => ( - `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`, `GeolocationPreview` and `PollPreview` are now exported from the root. +- `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 @@ -2246,7 +2246,7 @@ export const App = ({ children }: { children: React.ReactNode }) => ( ); ``` -The default poll creation dialog sends the poll as soon as it is created, so the poll preview only shows a poll created elsewhere. To have the dialog attach the poll to the message being composed instead: +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: @@ -2299,6 +2299,54 @@ export const App = ({ children }: { children: React.ReactNode }) => ( ); ``` +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/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx b/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx index 6797b4b9a3..8fd49cb424 100644 --- a/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx @@ -52,10 +52,11 @@ export const ComposerTab = ({ close }: ComposerTabProps) => { The previews above the textarea, rendered through{' '} MessageComposerPreviews with a{' '} messageComposerPreviewSet built from{' '} - defaultMessageComposerPreviewSet. 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. + 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.
diff --git a/examples/vite/src/AppSettings/tabs/Composer/PreviewSectionsField.tsx b/examples/vite/src/AppSettings/tabs/Composer/PreviewSectionsField.tsx index e88cfa1851..6edea10f43 100644 --- a/examples/vite/src/AppSettings/tabs/Composer/PreviewSectionsField.tsx +++ b/examples/vite/src/AppSettings/tabs/Composer/PreviewSectionsField.tsx @@ -1,7 +1,6 @@ -import type { DefaultMessageComposerPreviewType } from 'stream-chat-react'; - import { PREVIEW_SECTION_INFO, + type PreviewSectionType, resolvePreviewSectionOrder, } from '../../../ComposerPreviews/previewSections'; import { appSettingsStore, useAppSettingsState } from '../../state'; @@ -31,8 +30,7 @@ export const PreviewSectionsField = () => { appSettingsStore.partialNext({ composer: { ...composer, previewSectionOrder } }) } sections={resolvePreviewSectionOrder(composer.previewSectionOrder).map((type) => { - const { label, removes } = - PREVIEW_SECTION_INFO[type as DefaultMessageComposerPreviewType]; + const { label, removes } = PREVIEW_SECTION_INFO[type as PreviewSectionType]; return { description: ( <> diff --git a/examples/vite/src/ComposerPreviews/PollPreview.scss b/examples/vite/src/ComposerPreviews/PollPreview.scss new file mode 100644 index 0000000000..202eb97d87 --- /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/src/components/MessageComposer/PollPreview.tsx b/examples/vite/src/ComposerPreviews/PollPreview.tsx similarity index 64% rename from src/components/MessageComposer/PollPreview.tsx rename to examples/vite/src/ComposerPreviews/PollPreview.tsx index d484280aae..682d5d82e5 100644 --- a/src/components/MessageComposer/PollPreview.tsx +++ b/examples/vite/src/ComposerPreviews/PollPreview.tsx @@ -1,11 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import type { MessageComposerState, Poll, PollState } from 'stream-chat'; - -import { useChatContext } from '../../context/ChatContext'; -import { useComponentContextIcons, useTranslationContext } from '../../context'; -import { useStateStore } from '../../store'; -import { useMessageComposerController } from './hooks'; -import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; +import { + asDynamicKey, + type MessageComposerPreviewSetItem, + RemoveAttachmentPreviewButton, + useChatContext, + useComponentContextIcons, + useMessageComposerController, + useStateStore, + useTranslationContext, +} from 'stream-chat-react'; const messageComposerStateSelector = ({ pollId }: MessageComposerState) => ({ pollId }); @@ -33,16 +37,6 @@ 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 PollPreviewImage = () => { - const { IconPoll } = useComponentContextIcons(); - - return ( -
- -
- ); -}; - const PollPreviewData = ({ poll }: { poll: Poll }) => { const { t } = useTranslationContext(); const { @@ -64,36 +58,42 @@ const PollPreviewData = ({ poll }: { poll: Poll }) => { const settings = [ !enforce_unique_vote && (max_votes_allowed - ? t('messageComposer.pollPreview.maxVotes.text', { + ? t(asDynamicKey('viteExample.pollPreview.maxVotes.text'), { count: max_votes_allowed, defaultValue_one: 'Up to {{ count }} vote', defaultValue_other: 'Up to {{ count }} votes', }) - : t('messageComposer.pollPreview.multipleVotes.text', 'Multiple votes')), + : t( + asDynamicKey('viteExample.pollPreview.multipleVotes.text'), + 'Multiple votes', + )), voting_visibility === 'anonymous' && - t('messageComposer.pollPreview.anonymous.text', 'Anonymous'), + t(asDynamicKey('viteExample.pollPreview.anonymous.text'), 'Anonymous'), allow_user_suggested_options && - t('messageComposer.pollPreview.suggestOptions.text', 'Others can add options'), - allow_answers && t('messageComposer.pollPreview.comments.text', 'Comments'), + 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('messageComposer.pollPreview.optionCount.text', { +
+ + {t(asDynamicKey('viteExample.pollPreview.optionCount.text'), { count: options.length, defaultValue_one: '{{ count }} option', defaultValue_other: '{{ count }} options', })} - + {unlistedOptionCount > 0 - ? t('messageComposer.pollPreview.listedOptions.withMore.text', { + ? t(asDynamicKey('viteExample.pollPreview.listedOptions.withMore.text'), { count: unlistedOptionCount, defaultValue_one: '{{ options }} and {{ count }} more', defaultValue_other: '{{ options }} and {{ count }} more', @@ -106,13 +106,13 @@ const PollPreviewData = ({ poll }: { poll: Poll }) => { {settings.length > 0 && (
    {settings.map((setting) => ( -
  • +
  • {setting}
  • ))} @@ -124,12 +124,13 @@ const PollPreviewData = ({ poll }: { poll: Poll }) => { /** * Preview of the poll the composer holds (`messageComposer.state.pollId`), which goes out with - * the message. Shows the poll's question and options, fetching a poll that is not in + * 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(); @@ -137,6 +138,8 @@ export const PollPreview = () => { 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; @@ -165,19 +168,22 @@ export const PollPreview = () => { if (!pollId) return null; return ( -
    - +
    +
    + +
    {poll ? ( ) : ( -
    -
    - {t('common.poll.label', 'Poll')} -
    +
    +
    {t('common.poll.label', 'Poll')}
    )} @@ -189,3 +195,11 @@ export const PollPreview = () => {
    ); }; + +/** 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 index e5dc958419..db97ca138e 100644 --- a/examples/vite/src/ComposerPreviews/previewSections.ts +++ b/examples/vite/src/ComposerPreviews/previewSections.ts @@ -4,16 +4,24 @@ import { 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; }; -export const PREVIEW_SECTION_INFO: Record< - DefaultMessageComposerPreviewType, - PreviewSectionInfo -> = { +/** 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: @@ -34,7 +42,7 @@ export const PREVIEW_SECTION_INFO: Record< poll: { label: 'Poll', removes: - 'The Poll option in the attachment selector. While shown, the poll dialog attaches the poll to the message instead of sending it.', + "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', @@ -46,7 +54,7 @@ export const PREVIEW_SECTION_INFO: Record< }, }; -const DEFAULT_TYPES = defaultMessageComposerPreviewSet.map(({ type }) => type); +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 @@ -57,13 +65,11 @@ export const resolvePreviewSectionOrder = (order: string[]) => [ ...DEFAULT_TYPES.filter((type) => !order.includes(type)), ]; -/** `defaultMessageComposerPreviewSet` reordered and filtered by the Composer settings. */ +/** 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) => - defaultMessageComposerPreviewSet.filter((item) => item.type === type), - ); + .flatMap((type) => examplePreviewSet.filter((item) => item.type === type)); diff --git a/examples/vite/src/i18n/de.ts b/examples/vite/src/i18n/de.ts index de99478249..6eb3d10326 100644 --- a/examples/vite/src/i18n/de.ts +++ b/examples/vite/src/i18n/de.ts @@ -564,20 +564,6 @@ export const deTranslations = { 'Standortanhang entfernen', 'messageComposer.geolocationPreview.sharedLocation.title': 'Geteilter Standort', 'messageComposer.icons.attachFiles.text': 'Dateien anhängen', - 'messageComposer.pollPreview.anonymous.text': 'Anonym', - 'messageComposer.pollPreview.comments.text': 'Kommentare', - 'messageComposer.pollPreview.listedOptions.withMore.text_one': - '{{ options }} und {{ count }} weitere', - 'messageComposer.pollPreview.listedOptions.withMore.text_other': - '{{ options }} und {{ count }} weitere', - 'messageComposer.pollPreview.maxVotes.text_one': 'Bis zu {{ count }} Stimme', - 'messageComposer.pollPreview.maxVotes.text_other': 'Bis zu {{ count }} Stimmen', - 'messageComposer.pollPreview.multipleVotes.text': 'Mehrere Stimmen', - 'messageComposer.pollPreview.optionCount.text_one': '{{ count }} Option', - 'messageComposer.pollPreview.optionCount.text_other': '{{ count }} Optionen', - 'messageComposer.pollPreview.removePoll.ariaLabel': 'Umfrage entfernen', - 'messageComposer.pollPreview.settings.ariaLabel': 'Umfrageeinstellungen', - 'messageComposer.pollPreview.suggestOptions.text': 'Andere können Optionen hinzufügen', 'messageComposer.quotedMessagePreview.cancelReply.ariaLabel': 'Antwort abbrechen', 'messageComposer.quotedMessagePreview.files.label_one': '{{ count }} Datei', 'messageComposer.quotedMessagePreview.files.label_other': '{{ count }} Dateien', diff --git a/examples/vite/src/i18n/it.ts b/examples/vite/src/i18n/it.ts index de6445deaf..75c7705c3f 100644 --- a/examples/vite/src/i18n/it.ts +++ b/examples/vite/src/i18n/it.ts @@ -558,20 +558,6 @@ export const itTranslations = { 'Rimuovi l’allegato di posizione', 'messageComposer.geolocationPreview.sharedLocation.title': 'Posizione condivisa', 'messageComposer.icons.attachFiles.text': 'Allega file', - 'messageComposer.pollPreview.anonymous.text': 'Anonimo', - 'messageComposer.pollPreview.comments.text': 'Commenti', - 'messageComposer.pollPreview.listedOptions.withMore.text_one': - '{{ options }} e {{ count }} altra', - 'messageComposer.pollPreview.listedOptions.withMore.text_other': - '{{ options }} e altre {{ count }}', - 'messageComposer.pollPreview.maxVotes.text_one': 'Fino a {{ count }} voto', - 'messageComposer.pollPreview.maxVotes.text_other': 'Fino a {{ count }} voti', - 'messageComposer.pollPreview.multipleVotes.text': 'Voti multipli', - 'messageComposer.pollPreview.optionCount.text_one': '{{ count }} opzione', - 'messageComposer.pollPreview.optionCount.text_other': '{{ count }} opzioni', - 'messageComposer.pollPreview.removePoll.ariaLabel': 'Rimuovi sondaggio', - 'messageComposer.pollPreview.settings.ariaLabel': 'Impostazioni del sondaggio', - 'messageComposer.pollPreview.suggestOptions.text': 'Altri possono aggiungere opzioni', 'messageComposer.quotedMessagePreview.cancelReply.ariaLabel': 'Annulla risposta', 'messageComposer.quotedMessagePreview.files.label_one': '{{ count }} file', 'messageComposer.quotedMessagePreview.files.label_other': '{{ count }} file', diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 14714fc817..a63afc1daa 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/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx index 9015770d50..c880b5b30a 100644 --- a/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx +++ b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx @@ -18,7 +18,6 @@ import { EditedMessagePreview as DefaultEditedMessagePreview } from './EditedMes import { useMessageComposerController } from './hooks'; import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList'; import type { MessageComposerPreviewSetItem } from './MessageComposerPreviews'; -import { PollPreview as DefaultPollPreview } from './PollPreview'; import { restorePreEditSnapshot } from './preEditSnapshot'; import { QuotedMessagePreview as DefaultQuotedMessagePreview } from './QuotedMessagePreview'; @@ -81,10 +80,6 @@ const DefaultMessageComposerPreviewComponents = { /> ); }, - Poll() { - const { PollPreview = DefaultPollPreview } = useComponentContext(); - return ; - }, }; /** @@ -125,10 +120,4 @@ export const defaultMessageComposerPreviewSet: MessageComposerPreviewSetItem[] = isVisible: ({ location }) => !!location, type: 'location', }, - { - Component: DefaultMessageComposerPreviewComponents.Poll, - // An edited message keeps its poll; it cannot be replaced or removed. - isVisible: ({ editedMessage, pollId }) => !editedMessage && !!pollId, - type: 'poll', - }, ]; diff --git a/src/components/MessageComposer/MessageComposerPreviews.tsx b/src/components/MessageComposer/MessageComposerPreviews.tsx index 167530cf3d..218fd3e710 100644 --- a/src/components/MessageComposer/MessageComposerPreviews.tsx +++ b/src/components/MessageComposer/MessageComposerPreviews.tsx @@ -13,7 +13,6 @@ export type DefaultMessageComposerPreviewType = | 'editedMessage' | 'linkPreviews' | 'location' - | 'poll' | 'quotedMessage' | 'voiceRecording'; @@ -33,7 +32,7 @@ export type MessageComposerPreviewsProps = { /** * Renders the composer's visible previews (edited or quoted message, voice recording, - * attachments, link previews, location, poll) above the textarea, or nothing when none is visible. + * attachments, link previews, location) above the textarea, or nothing when none is visible. */ export const MessageComposerPreviews = ({ messageComposerPreviewSet = defaultMessageComposerPreviewSet, diff --git a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx index c0d79e3c5d..f8fb8b886c 100644 --- a/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageComposerPreviews.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/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'; @@ -74,15 +74,10 @@ const addFileAttachment = (channel: ChannelType) => { ]); }; -const cachePoll = (client: StreamChat, poll = generatePoll()) => { - client.polls.hydratePollCache([fromPartial({ poll })]); - return poll; -}; - 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='poll-preview'], .${PREVIEWS_CLASS} [data-testid='custom-preview']`, + `.${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')); @@ -102,7 +97,6 @@ describe('MessageComposerPreviews', () => { it('renders the default previews in order', async () => { const { channel, client } = await setup(); const { container } = await renderPreviews({ channel, client }); - cachePoll(client); await act(() => { channel.messageComposer.setQuotedMessage( @@ -111,7 +105,6 @@ describe('MessageComposerPreviews', () => { addFileAttachment(channel); addLinkPreview(channel); channel.messageComposer.locationComposer.setData({ latitude: 1, longitude: 2 }); - channel.messageComposer.state.partialNext({ pollId: generatePoll().id }); }); expect(container.querySelectorAll(`.${PREVIEWS_CLASS}`)).toHaveLength(1); @@ -120,11 +113,10 @@ describe('MessageComposerPreviews', () => { 'attachment-preview-list', 'link-preview-card', 'location-preview', - 'poll-preview', ]); }); - it('shows the edited message instead of the quoted message and the poll', async () => { + it('shows the edited message instead of the quoted message', async () => { const { channel, client } = await setup(); const { container } = await renderPreviews({ channel, client }); @@ -133,14 +125,12 @@ describe('MessageComposerPreviews', () => { editedMessage: fromPartial( generateMessage({ text: 'edited', user: generateUser() }), ), - pollId: 'poll-id', quotedMessage: fromPartial(generateMessage({ text: 'quoted' })), }); }); expect(screen.getByText('Edit Message')).toBeInTheDocument(); expect(screen.queryByText('quoted')).not.toBeInTheDocument(); - expect(screen.queryByTestId('poll-preview')).not.toBeInTheDocument(); expect(container.querySelector(`.${PREVIEWS_CLASS}`)).toBeInTheDocument(); }); @@ -211,135 +201,46 @@ describe('MessageComposerPreviews', () => { expect(screen.getByTestId('custom-preview')).toBeInTheDocument(); }); - describe('poll preview', () => { - it('renders the preview area for a poll-only composer', async () => { - const { channel, client } = await setup(); - const poll = cachePoll(client); - const { container } = await renderPreviews({ channel, client }); - - await act(() => { - channel.messageComposer.state.partialNext({ pollId: poll.id }); - }); - - const pollPreview = screen.getByTestId('poll-preview'); - expect(container.querySelector(`.${PREVIEWS_CLASS}`)).toContainElement(pollPreview); - expect(pollPreview).toHaveTextContent(poll.name); - // The default generated poll: 4 options, up to 2 votes, public, comments on. - expect(pollPreview).toHaveTextContent('4 options'); - expect(pollPreview).toHaveTextContent('A, B, C and 1 more'); - expect( - within(screen.getByRole('list', { name: 'Poll settings' })) - .getAllByRole('listitem') - .map((item) => item.textContent), - ).toEqual(['Up to 2 votes', 'Comments']); - }); - - it('names only the first options of a long poll and reflects its settings', async () => { - const { channel, client } = await setup(); - const poll = cachePoll( - client, - generatePoll({ - allow_answers: false, - allow_user_suggested_options: true, - enforce_unique_vote: true, - id: 'long-poll', - options: Array.from({ length: 99 }, (_, index) => ({ - id: `option-${index}`, - text: `Option ${index + 1}`, - })), - voting_visibility: 'anonymous', - }), - ); - await renderPreviews({ channel, client }); - - await act(() => { - channel.messageComposer.state.partialNext({ pollId: poll.id }); - }); - - const pollPreview = screen.getByTestId('poll-preview'); - expect(pollPreview).toHaveTextContent('99 options'); - expect(pollPreview).toHaveTextContent('Option 1, Option 2, Option 3 and 96 more'); - expect(pollPreview).not.toHaveTextContent('Option 4'); - expect( - within(screen.getByRole('list', { name: 'Poll settings' })) - .getAllByRole('listitem') - .map((item) => item.textContent), - ).toEqual(['Anonymous', 'Others can add options']); - }); - - it('fetches a poll that is not cached', async () => { - const { channel, client } = await setup(); - const poll = generatePoll({ id: 'uncached-poll', name: 'Fetched question' }); - const getPollSpy = vi - .spyOn(client, 'getPoll') - .mockResolvedValue(fromPartial({ poll })); - await renderPreviews({ channel, client }); - - await act(() => { - channel.messageComposer.state.partialNext({ pollId: poll.id }); - }); - - expect(getPollSpy).toHaveBeenCalledWith({ poll_id: poll.id }); - expect(await screen.findByText('Fetched question')).toBeInTheDocument(); - }); - - it('detaches a poll that no longer exists', async () => { - const { channel, client } = await setup(); - vi.spyOn(client, 'getPoll').mockRejectedValue( - Object.assign(new Error('poll not found'), { code: 16 }), - ); - const { container } = await renderPreviews({ channel, client }); - - await act(() => { - channel.messageComposer.state.partialNext({ pollId: 'deleted-poll' }); - }); + it('shows no default preview for a poll the composer holds', async () => { + const { channel, client } = await setup(); + const { container } = await renderPreviews({ channel, client }); - expect(channel.messageComposer.pollId).toBeNull(); - expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument(); + await act(() => { + channel.messageComposer.state.partialNext({ pollId: generatePoll().id }); }); - it('keeps the poll when fetching it fails for another reason', async () => { - const { channel, client } = await setup(); - vi.spyOn(client, 'getPoll').mockRejectedValue(new Error('Network Error')); - await renderPreviews({ channel, client }); + expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument(); + }); - await act(() => { - channel.messageComposer.state.partialNext({ pollId: 'unreachable-poll' }); - }); + it('has no accessibility violations', async () => { + const { channel, client } = await setup(); + const { container } = await renderPreviews({ channel, client }); - expect(channel.messageComposer.pollId).toBe('unreachable-poll'); - expect(screen.getByTestId('poll-preview')).toHaveTextContent('Poll'); + await act(() => { + addFileAttachment(channel); + addLinkPreview(channel); }); - it('detaches the poll from the composer on remove without deleting it', async () => { - const { channel, client } = await setup(); - const poll = cachePoll(client); - const deletePollSpy = vi.spyOn(client, 'deletePoll'); - const { container } = await renderPreviews({ channel, client }); + expect(await axe(container)).toHaveNoViolations(); + }); - await act(() => { - channel.messageComposer.state.partialNext({ pollId: poll.id }); - }); - await act(() => { - fireEvent.click(screen.getByTestId('poll-preview-delete-button')); - }); + 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 }); - expect(channel.messageComposer.pollId).toBeNull(); - expect(container.querySelector(`.${PREVIEWS_CLASS}`)).not.toBeInTheDocument(); - expect(deletePollSpy).not.toHaveBeenCalled(); + await act(() => { + channel.messageComposer.state.partialNext({ pollId: generatePoll().id }); }); - it('has no accessibility violations', async () => { - const { channel, client } = await setup(); - const poll = cachePoll(client); - const { container } = await renderPreviews({ channel, client }); - - await act(() => { - channel.messageComposer.state.partialNext({ pollId: poll.id }); - addLinkPreview(channel); - }); - - expect(await axe(container)).toHaveNoViolations(); - }); + expect(previewTestIds(container)).toEqual(['custom-preview']); }); }); diff --git a/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts b/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts index 15554133e1..4b48067980 100644 --- a/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts +++ b/src/components/MessageComposer/hooks/useMessageComposerPreviewState.ts @@ -50,7 +50,10 @@ export type MessageComposerPreviewVisibilityFilterParams = { /** Link previews that are loading or loaded. */ linkPreviews: LinkPreview[]; location: LocationComposerState['location']; - /** A poll created with `messageComposer.createPoll()` that goes out with the message. */ + /** + * 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']; }; diff --git a/src/components/MessageComposer/index.ts b/src/components/MessageComposer/index.ts index 4e48cb7bf8..09f0b2ba44 100644 --- a/src/components/MessageComposer/index.ts +++ b/src/components/MessageComposer/index.ts @@ -26,7 +26,6 @@ export * from './MessageComposerActions'; export * from './MessageComposerPreviews'; export * from './MessageComposerPreviews.defaults'; export * from './MessageComposerUI'; -export * from './PollPreview'; export * from './QuotedMessagePreview'; export * from './RemoveAttachmentPreviewButton'; export * from './SendButton'; diff --git a/src/components/MessageComposer/styling/AttachmentPreviewThumbnail.scss b/src/components/MessageComposer/styling/AttachmentPreviewThumbnail.scss index a8d47a6976..a79d129585 100644 --- a/src/components/MessageComposer/styling/AttachmentPreviewThumbnail.scss +++ b/src/components/MessageComposer/styling/AttachmentPreviewThumbnail.scss @@ -1,6 +1,5 @@ // todo: should we have img dimensions determined by semantic variables? .str-chat__location-preview-image, -.str-chat__poll-preview-image, .str-chat__link-preview-card .str-chat__image-placeholder, .str-chat__attachment-preview__thumbnail { border-radius: var(--str-chat__radius-md); diff --git a/src/components/MessageComposer/styling/PollPreview.scss b/src/components/MessageComposer/styling/PollPreview.scss deleted file mode 100644 index 1739ffbc49..0000000000 --- a/src/components/MessageComposer/styling/PollPreview.scss +++ /dev/null @@ -1,80 +0,0 @@ -@use '../../../styling/utils'; - -.str-chat__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); - - .str-chat__poll-preview-image { - display: flex; - align-items: center; - justify-content: center; - background-color: var(--str-chat__background-core-overlay-light); - width: 40px; - height: 40px; - - .str-chat__icon { - width: 20px; - height: 20px; - } - } - - .str-chat__poll-preview__data { - display: flex; - flex-direction: column; - justify-content: center; - min-width: 0; - flex: 1; - height: 100%; - gap: 2px; - - .str-chat__poll-preview__data__title { - @include utils.ellipsis-text(); - font: var(--str-chat__font-metadata-emphasis); - } - - .str-chat__poll-preview__data__options { - display: flex; - gap: var(--str-chat__spacing-xxs); - min-width: 0; - font: var(--str-chat__font-metadata-default); - } - - .str-chat__poll-preview__data__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. - .str-chat__poll-preview__data__options-list { - @include utils.ellipsis-text(); - min-width: 0; - color: var(--str-chat__text-secondary); - } - - .str-chat__poll-preview__data__settings { - display: flex; - flex-wrap: wrap; - gap: var(--str-chat__spacing-xxs); - margin: 2px 0 0; - padding: 0; - list-style: none; - } - - .str-chat__poll-preview__data__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/src/components/MessageComposer/styling/index.scss b/src/components/MessageComposer/styling/index.scss index d30e925318..ea2fc8891c 100644 --- a/src/components/MessageComposer/styling/index.scss +++ b/src/components/MessageComposer/styling/index.scss @@ -7,7 +7,6 @@ @use 'GeologacationPreview'; @use 'LinkPreviewList'; @use 'MessageComposer'; -@use 'PollPreview'; @use 'QuotedMessageIndicator'; @use 'QuotedMessagePreview'; @use 'RemoveAttachmentPreviewButton'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 0eb40933ea..be0f171110 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -255,8 +255,6 @@ export type ComponentContextValue = { */ MessageAlsoSentInChannelIndicator?: React.ComponentType; PinIndicator?: React.ComponentType; - /** Preview of the poll the message composer holds (`messageComposer.state.pollId`). */ - PollPreview?: React.ComponentType; /** Poll actions in a message. */ PollActions?: React.ComponentType; /** Poll rendering in a message. */ diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json index 80bf9aeec4..0f6be75507 100644 --- a/src/i18n/__tests__/catalog.fixture.json +++ b/src/i18n/__tests__/catalog.fixture.json @@ -407,18 +407,6 @@ "messageComposer.geolocationPreview.removeLocationAttachment.ariaLabel": "Remove location attachment", "messageComposer.geolocationPreview.sharedLocation.title": "Shared location", "messageComposer.icons.attachFiles.text": "Attach files", - "messageComposer.pollPreview.anonymous.text": "Anonymous", - "messageComposer.pollPreview.comments.text": "Comments", - "messageComposer.pollPreview.listedOptions.withMore.text_one": "{{ options }} and {{ count }} more", - "messageComposer.pollPreview.listedOptions.withMore.text_other": "{{ options }} and {{ count }} more", - "messageComposer.pollPreview.maxVotes.text_one": "Up to {{ count }} vote", - "messageComposer.pollPreview.maxVotes.text_other": "Up to {{ count }} votes", - "messageComposer.pollPreview.multipleVotes.text": "Multiple votes", - "messageComposer.pollPreview.optionCount.text_one": "{{ count }} option", - "messageComposer.pollPreview.optionCount.text_other": "{{ count }} options", - "messageComposer.pollPreview.removePoll.ariaLabel": "Remove poll", - "messageComposer.pollPreview.settings.ariaLabel": "Poll settings", - "messageComposer.pollPreview.suggestOptions.text": "Others can add options", "messageComposer.quotedMessagePreview.cancelReply.ariaLabel": "Cancel Reply", "messageComposer.quotedMessagePreview.files.label_one": "{{ count }} file", "messageComposer.quotedMessagePreview.files.label_other": "{{ count }} files", diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index 1fe9a4ba0d..362e512695 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -418,18 +418,6 @@ export type TranslationCatalog = { 'messageComposer.geolocationPreview.removeLocationAttachment.ariaLabel': 'Remove location attachment'; 'messageComposer.geolocationPreview.sharedLocation.title': 'Shared location'; 'messageComposer.icons.attachFiles.text': 'Attach files'; - 'messageComposer.pollPreview.anonymous.text': 'Anonymous'; - 'messageComposer.pollPreview.comments.text': 'Comments'; - 'messageComposer.pollPreview.listedOptions.withMore.text_one': '{{ options }} and {{ count }} more'; - 'messageComposer.pollPreview.listedOptions.withMore.text_other': '{{ options }} and {{ count }} more'; - 'messageComposer.pollPreview.maxVotes.text_one': 'Up to {{ count }} vote'; - 'messageComposer.pollPreview.maxVotes.text_other': 'Up to {{ count }} votes'; - 'messageComposer.pollPreview.multipleVotes.text': 'Multiple votes'; - 'messageComposer.pollPreview.optionCount.text_one': '{{ count }} option'; - 'messageComposer.pollPreview.optionCount.text_other': '{{ count }} options'; - 'messageComposer.pollPreview.removePoll.ariaLabel': 'Remove poll'; - 'messageComposer.pollPreview.settings.ariaLabel': 'Poll settings'; - 'messageComposer.pollPreview.suggestOptions.text': 'Others can add options'; 'messageComposer.quotedMessagePreview.cancelReply.ariaLabel': 'Cancel Reply'; 'messageComposer.quotedMessagePreview.files.label_one': '{{ count }} file'; 'messageComposer.quotedMessagePreview.files.label_other': '{{ count }} files'; From bef2e438836283a05ff29b22354adb26ef742f41 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 14:41:49 +0100 Subject: [PATCH 17/18] docs(ai-docs): catch createPoll() failures in the attach-poll example --- ai-docs/ai-migration-v14-v15.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 9ddc5aa171..2886e17c6c 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2279,9 +2279,13 @@ const AttachPollControls = ({ close }: PollCreationDialogControlsProps) => { { - // The poll waits in `messageComposer.state.pollId` and goes out with the message. - await messageComposer.createPoll(); 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' > From e5256ffc693c3520ee451fb636584498d5f1e3cd Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 15:50:11 +0100 Subject: [PATCH 18/18] chore(examples): detach the cookbook recipe's poll on remove without deleting it --- .../AddPollDialog.tsx | 12 ++---------- .../ComposerPollPreview.tsx | 2 +- .../removePendingPoll.ts | 19 ++++--------------- 3 files changed, 7 insertions(+), 26 deletions(-) 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 5107511d9e..d4783c8957 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 8a03dc8bdf..a90adc3c14 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 4e1e66c3ab..ef9cee5844 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); };