From 25d3a9d3843238d5c5f819738557a97561f5f873 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 25 Sep 2026 10:40:39 +0200 Subject: [PATCH 1/6] fix(MessageComposer): save an edit when it is submitted with Enter Enter always called `messageComposer.send()`, while the send button chose between `update()` and `send()` itself. Submitting an edit with Enter therefore posted a new message under the edited message's id, which the server rejects as already existing. Both now go through `useMessageComposerSubmitFn`, which picks `update()` while a message is being edited and `send()` otherwise, deciding when submitting rather than when rendering. Co-Authored-By: Claude Opus 5.5 --- .../MessageComposerActions.tsx | 8 +++- .../__tests__/MessageInput.test.tsx | 37 +++++++++++++++++++ src/components/MessageComposer/hooks/index.ts | 1 + .../hooks/useMessageComposerSubmitFn.ts | 18 +++++++++ .../TextareaComposer/TextareaComposer.tsx | 7 ++-- 5 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts diff --git a/src/components/MessageComposer/MessageComposerActions.tsx b/src/components/MessageComposer/MessageComposerActions.tsx index d8e312b2ee..b1f92fba0b 100644 --- a/src/components/MessageComposer/MessageComposerActions.tsx +++ b/src/components/MessageComposer/MessageComposerActions.tsx @@ -9,7 +9,11 @@ import { useMessageComposerContext, } from '../../context'; import { useAIState } from '../AIStateIndicator'; -import { useMessageComposerController, useMessageContentIsEmpty } from './hooks'; +import { + useMessageComposerController, + useMessageComposerSubmitFn, + useMessageContentIsEmpty, +} from './hooks'; import { AudioRecordingButtonWithNotification } from '../MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification'; import { useIsCooldownActive } from './hooks/useIsCooldownActive'; import { AIStates } from 'stream-chat'; @@ -67,7 +71,7 @@ export const MessageComposerActions = () => { : StopAIGenerationButtonOverride; const { recordingController } = useMessageComposerContext(); - const submitMessageFn = editedMessage ? messageComposer.update : messageComposer.send; + const submitMessageFn = useMessageComposerSubmitFn(); const isCooldownActive = useIsCooldownActive(); const { aiState } = useAIState(channel); diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index c404e5fee5..3f9e1c74cf 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -2049,6 +2049,43 @@ describe(`MessageInputFlat`, () => { }); }; + // The send button and the Enter key are two ways to submit, and both have to save an edit - + // sending it would post a new message under the id of the one being edited. + it.each([ + [ + 'the Enter key', + (input: HTMLElement) => fireEvent.keyDown(input, { key: 'Enter' }), + ], + ['the send button', () => fireEvent.click(screen.getByTestId('send-button'))], + ])('saves the edit when submitted with %s', async (_, submit) => { + const { channel } = await renderComponent(); + const sendMessage = vi + .spyOn(channel, 'sendMessageWithLocalUpdate') + .mockResolvedValue(undefined); + const updateMessage = vi + .spyOn(channel, 'updateMessageWithLocalUpdate') + .mockResolvedValue(undefined); + const input = await screen.findByPlaceholderText(inputPlaceholder); + await enterEditMode(); + await waitFor(() => expect(input).toHaveValue(mainListMessage.text)); + + await act(async () => { + await fireEvent.change(input, { target: { value: 'edited text' } }); + }); + await act(() => submit(input)); + + await waitFor(() => expect(updateMessage).toHaveBeenCalledTimes(1)); + expect(updateMessage).toHaveBeenCalledWith( + expect.objectContaining({ + localMessage: expect.objectContaining({ + id: mainListMessage.id, + text: 'edited text', + }), + }), + ); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it('should restore composer text when cancelling edit mode', async () => { await renderComponent(); const textarea = await screen.findByPlaceholderText(inputPlaceholder); diff --git a/src/components/MessageComposer/hooks/index.ts b/src/components/MessageComposer/hooks/index.ts index 14fb3d5a11..a387e28eb7 100644 --- a/src/components/MessageComposer/hooks/index.ts +++ b/src/components/MessageComposer/hooks/index.ts @@ -6,4 +6,5 @@ export * from './useMessageComposerBindings'; export * from './useMessageComposerCommands'; export * from './useMessageComposerController'; export * from './useMessageComposerHasSendableData'; +export * from './useMessageComposerSubmitFn'; export * from './useMessageContentIsEmpty'; diff --git a/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts b/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts new file mode 100644 index 0000000000..0708c8331b --- /dev/null +++ b/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; + +import { useMessageComposerController } from './useMessageComposerController'; + +/** + * Submits what the composer holds: saves the edit while a message is being edited, sends a new + * message otherwise. Checked when submitting rather than when rendering, and shared by every submit + * control, so the send button and the Enter key cannot disagree about it. + */ +export const useMessageComposerSubmitFn = () => { + const messageComposer = useMessageComposerController(); + + return useCallback( + () => + messageComposer.editedMessage ? messageComposer.update() : messageComposer.send(), + [messageComposer], + ); +}; diff --git a/src/components/TextareaComposer/TextareaComposer.tsx b/src/components/TextareaComposer/TextareaComposer.tsx index 76299b86bc..c075915738 100644 --- a/src/components/TextareaComposer/TextareaComposer.tsx +++ b/src/components/TextareaComposer/TextareaComposer.tsx @@ -35,6 +35,7 @@ import { import { useTextareaPlaceholder } from './hooks/useTextareaPlaceholder'; import { useAriaLiveAnnouncer, useInteractionAnnouncements } from '../Accessibility'; import { useMessageComposerHasSendableData } from '../MessageComposer/hooks/useMessageComposerHasSendableData'; +import { useMessageComposerSubmitFn } from '../MessageComposer/hooks/useMessageComposerSubmitFn'; const textComposerStateSelector = (state: TextComposerState) => ({ selection: state.selection, @@ -164,7 +165,7 @@ const TextareaComposerWithLiveAnnouncements = ({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', } satisfies React.CSSProperties); - const sendMessage = messageComposer.send; + const submitMessage = useMessageComposerSubmitFn(); const { enabled } = useStateStore(messageComposer.configState, configStateSelector); const { quotedMessage } = useStateStore( @@ -309,15 +310,15 @@ const TextareaComposerWithLiveAnnouncements = ({ // prevent adding newline when submitting a message with event.preventDefault(); } - sendMessage(); + submitMessage(); } }, [ focusedItemIndex, hasSendableData, onKeyDown, - sendMessage, shouldSubmit, + submitMessage, textComposer, textareaRef, ], From ca576f47e0fbcb0865816c088f3dcbb08f7ef0e3 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 25 Sep 2026 10:53:23 +0200 Subject: [PATCH 2/6] feat(MessageComposer): let integrators supply the composer MessageComposer edits Ports GetStream/stream-chat-react#3249 onto v15. `MessageComposerControllerProvider` supplies the composer for every `MessageComposer` below it, ahead of the thread's and the channel's own - for a composition the integrator owns, such as editing a message inline. `useMessageComposerController` consults it first. `preventClearingOnUnmount` keeps that composer's state when `MessageComposer` unmounts instead of clearing it. A draft save that fails on unmount is caught rather than left unhandled. Two departures from #3249: the context lives in `src/context/`, since importing it from `MessageComposer.tsx` into the hook made a cycle, and the unmount path drops its `config.drafts.enabled` check, which `createDraft()` already performs. Co-Authored-By: Claude Opus 5.5 --- .../MessageComposer/MessageComposer.tsx | 13 +- .../__tests__/MessageInput.test.tsx | 90 ++++++++++- .../useMessageComposerController.test.tsx | 141 ++++++++++++++++++ .../hooks/useMessageComposerController.ts | 17 ++- .../MessageComposerControllerContext.tsx | 27 ++++ src/context/index.ts | 1 + 6 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx create mode 100644 src/context/MessageComposerControllerContext.tsx diff --git a/src/components/MessageComposer/MessageComposer.tsx b/src/components/MessageComposer/MessageComposer.tsx index 6bd0593f6e..4ea6b3831a 100644 --- a/src/components/MessageComposer/MessageComposer.tsx +++ b/src/components/MessageComposer/MessageComposer.tsx @@ -72,6 +72,12 @@ export type MessageComposerProps = { * ``` */ shouldSubmit?: (event: React.KeyboardEvent) => boolean; + /** + * Keeps the composer's state when this component unmounts instead of clearing it. For a composer + * the integrator owns - one supplied through `MessageComposerControllerProvider` - where + * unmounting the UI does not end the composition. + */ + preventClearingOnUnmount?: boolean; }; const MessageComposerProvider = (props: PropsWithChildren) => { @@ -88,9 +94,12 @@ const MessageComposerProvider = (props: PropsWithChildren) useEffect( () => () => { - messageComposer.createDraft().finally(() => messageComposer.clear()); + // `createDraft` already skips edits and composers with drafts disabled. + const draftSaved = messageComposer.createDraft().catch(console.error); + if (props.preventClearingOnUnmount) return; + draftSaved.finally(() => messageComposer.clear()); }, - [messageComposer], + [messageComposer, props.preventClearingOnUnmount], ); useEffect(() => { diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index 3f9e1c74cf..69e5975153 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -20,7 +20,11 @@ import type { UploadChannelResponse, UserResponse, } from 'stream-chat'; -import { LinkPreviewStatus, SearchController } from 'stream-chat'; +import { + LinkPreviewStatus, + MessageComposer as MessageComposerController, + SearchController, +} from 'stream-chat'; import { act, cleanup, @@ -46,7 +50,12 @@ import type { ComponentContextValue, MessageContextValue, } from '../../../context'; -import { DialogManagerProvider, MessageProvider, WithComponents } from '../../../context'; +import { + DialogManagerProvider, + MessageComposerControllerProvider, + MessageProvider, + WithComponents, +} from '../../../context'; import { ChatProvider } from '../../../context/ChatContext'; import { dispatchMessageDeletedEvent, @@ -253,6 +262,7 @@ const renderComponent = async ({ customClient, customUser, messageActionsProps = {}, + messageComposerController, messageContextOverrides = {}, messageInputProps = {}, }: { @@ -264,6 +274,7 @@ const renderComponent = async ({ customClient?: StreamChat; customUser?: UserResponse; messageActionsProps?: Partial; + messageComposerController?: MessageComposerController; messageContextOverrides?: Partial; messageInputProps?: Partial; [key: string]: unknown; @@ -309,7 +320,11 @@ const renderComponent = async ({ {...messageActionsProps} /> - + + + @@ -2214,4 +2229,73 @@ describe(`MessageInputFlat`, () => { Element.prototype.scrollIntoView = scrollIntoView; }); }); + + describe('On unmount', () => { + // The cleanup chains `clear()` onto the draft save, so it lands a few microtasks later. + const flushMicrotasks = () => + act(() => new Promise((resolve) => setTimeout(resolve, 0))); + + it('saves a draft and clears the composer', async () => { + const { customChannel, customClient } = await setup(); + const createDraft = vi.spyOn(customChannel.messageComposer, 'createDraft'); + const clear = vi.spyOn(customChannel.messageComposer, 'clear'); + const { unmount } = await renderComponent({ customChannel, customClient }); + + unmount(); + + expect(createDraft).toHaveBeenCalledTimes(1); + await waitFor(() => expect(clear).toHaveBeenCalledTimes(1)); + }); + + it('still saves the draft but keeps the state with preventClearingOnUnmount', async () => { + const { customChannel, customClient } = await setup(); + const createDraft = vi.spyOn(customChannel.messageComposer, 'createDraft'); + const clear = vi.spyOn(customChannel.messageComposer, 'clear'); + const { unmount } = await renderComponent({ + customChannel, + customClient, + messageInputProps: { preventClearingOnUnmount: true }, + }); + + unmount(); + await flushMicrotasks(); + + expect(createDraft).toHaveBeenCalledTimes(1); + expect(clear).not.toHaveBeenCalled(); + }); + + it('clears the composer even when saving the draft fails', async () => { + const { customChannel, customClient } = await setup(); + const error = new Error('draft request failed'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(customChannel.messageComposer, 'createDraft').mockRejectedValue(error); + const clear = vi.spyOn(customChannel.messageComposer, 'clear'); + const { unmount } = await renderComponent({ customChannel, customClient }); + + unmount(); + + await waitFor(() => expect(clear).toHaveBeenCalledTimes(1)); + expect(consoleError).toHaveBeenCalledWith(error); + }); + + it('acts on a supplied composer, not the channel one', async () => { + const { customChannel, customClient } = await setup(); + const supplied = new MessageComposerController({ + client: customClient, + compositionContext: customChannel, + }); + const clearSupplied = vi.spyOn(supplied, 'clear'); + const clearChannel = vi.spyOn(customChannel.messageComposer, 'clear'); + const { unmount } = await renderComponent({ + customChannel, + customClient, + messageComposerController: supplied, + }); + + unmount(); + + await waitFor(() => expect(clearSupplied).toHaveBeenCalledTimes(1)); + expect(clearChannel).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx new file mode 100644 index 0000000000..ef894b95c4 --- /dev/null +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import type { PropsWithChildren } from 'react'; +import { act, renderHook, type RenderHookResult } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { + type Channel, + MessageComposer as MessageComposerController, + type StreamChat, + type Thread, +} from 'stream-chat'; + +import { useMessageComposerController } from '../useMessageComposerController'; +import { Chat } from '../../../Chat'; +import { Channel as ChannelComponent } from '../../../Channel'; +import { ThreadProvider } from '../../../Threads'; +import { MessageComposerControllerProvider } from '../../../../context'; +import { + getOrCreateChannelApi, + getTestClientWithUser, + useMockedApis, +} from '../../../../mock-builders'; +import { generateChannel } from '../../../../mock-builders/generator'; + +const buildComposer = (client: StreamChat, channel: Channel) => + new MessageComposerController({ client, compositionContext: channel }); + +const stubThread = (messageComposer: MessageComposerController) => + fromPartial({ messageComposer }); + +const setup = async ({ + channel, + client, + suppliedComposer, + thread, +}: { + channel: Channel; + client: StreamChat; + suppliedComposer?: MessageComposerController; + thread?: Thread; +}) => { + const wrapper = ({ children }: PropsWithChildren) => ( + + + + + {children} + + + + + ); + + let result!: RenderHookResult; + await act(() => { + result = renderHook(() => useMessageComposerController(), { wrapper }); + }); + return result; +}; + +describe('useMessageComposerController', () => { + let client: StreamChat; + let channel: Channel; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'test-user' }); + const mockedChannelData = generateChannel(); + useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); + channel = client.channel('messaging', mockedChannelData.channel.id); + await channel.watch(); + }); + + describe('which composer it resolves', () => { + it("falls back to the channel's own composer", async () => { + const { result } = await setup({ channel, client }); + + expect(result.current).toBe(channel.messageComposer); + }); + + it("prefers the thread's composer over the channel's", async () => { + const threadComposer = buildComposer(client, channel); + + const { result } = await setup({ + channel, + client, + thread: stubThread(threadComposer), + }); + + expect(result.current).toBe(threadComposer); + }); + + it('prefers a supplied composer over the channel', async () => { + const suppliedComposer = buildComposer(client, channel); + + const { result } = await setup({ channel, client, suppliedComposer }); + + expect(result.current).toBe(suppliedComposer); + }); + + it('prefers a supplied composer over a thread', async () => { + const suppliedComposer = buildComposer(client, channel); + + const { result } = await setup({ + channel, + client, + suppliedComposer, + thread: stubThread(buildComposer(client, channel)), + }); + + expect(result.current).toBe(suppliedComposer); + }); + }); + + describe('subscriptions', () => { + it('registers them on the resolved composer and releases them on unmount', async () => { + const unsubscribe = vi.fn(); + const registerSubscriptions = vi + .spyOn(channel.messageComposer, 'registerSubscriptions') + .mockReturnValue(unsubscribe); + + const { unmount } = await setup({ channel, client }); + + expect(registerSubscriptions).toHaveBeenCalled(); + expect(unsubscribe).not.toHaveBeenCalled(); + + unmount(); + + expect(unsubscribe).toHaveBeenCalled(); + }); + + it('registers them on a supplied composer rather than the channel one', async () => { + const suppliedComposer = buildComposer(client, channel); + const onSupplied = vi.spyOn(suppliedComposer, 'registerSubscriptions'); + const onChannel = vi.spyOn(channel.messageComposer, 'registerSubscriptions'); + + await setup({ channel, client, suppliedComposer }); + + expect(onSupplied).toHaveBeenCalled(); + expect(onChannel).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/MessageComposer/hooks/useMessageComposerController.ts b/src/components/MessageComposer/hooks/useMessageComposerController.ts index 65b4b168cc..a0504545fc 100644 --- a/src/components/MessageComposer/hooks/useMessageComposerController.ts +++ b/src/components/MessageComposer/hooks/useMessageComposerController.ts @@ -1,22 +1,27 @@ import { useEffect, useMemo } from 'react'; import type { MessageComposer as MessageComposerController } from 'stream-chat'; import { useThreadContext } from '../../Threads'; -import { useChannel, useChatContext } from '../../../context'; +import { + useChannel, + useChatContext, + useMessageComposerControllerContext, +} from '../../../context'; export const useMessageComposerController = () => { const { client } = useChatContext(); const { messageComposerCache: queueCache } = client; const channel = useChannel(); const threadInstance = useThreadContext(); + const suppliedComposer = useMessageComposerControllerContext(); - // composer hierarchy: thread instance (own) -> channel (own) + // composer hierarchy: supplied by the integrator -> thread instance (own) -> channel (own) const messageComposer = useMemo( - () => threadInstance?.messageComposer ?? channel.messageComposer, - [channel, threadInstance], + () => suppliedComposer ?? threadInstance?.messageComposer ?? channel.messageComposer, + [channel, suppliedComposer, threadInstance], ); - // `legacy_thread` used to be reachable here too, from a composer built off a parent message; the - // only composers this hook resolves now belong to a thread or a channel. + // Only a supplied composer can carry a message context (an edit); the thread's and channel's own + // never do. if ( (['message'] as MessageComposerController['contextType'][]).includes( messageComposer.contextType, diff --git a/src/context/MessageComposerControllerContext.tsx b/src/context/MessageComposerControllerContext.tsx new file mode 100644 index 0000000000..75cbe24462 --- /dev/null +++ b/src/context/MessageComposerControllerContext.tsx @@ -0,0 +1,27 @@ +import type { PropsWithChildren } from 'react'; +import React, { useContext } from 'react'; +import type { MessageComposer as MessageComposerController } from 'stream-chat'; + +const MessageComposerControllerContext = React.createContext< + MessageComposerController | undefined +>(undefined); + +/** + * Supplies the composer a `MessageComposer` below it edits, in place of the thread's or channel's own. + * For a composition the integrator owns - editing a message inline, for instance - where the default + * composer is the wrong one to write into. + */ +export const MessageComposerControllerProvider = ({ + children, + messageComposerController, +}: PropsWithChildren<{ + messageComposerController?: MessageComposerController; +}>) => ( + + {children} + +); + +/** The composer supplied by {@link MessageComposerControllerProvider}, if any. */ +export const useMessageComposerControllerContext = () => + useContext(MessageComposerControllerContext); diff --git a/src/context/index.ts b/src/context/index.ts index 73a783e477..e246f71b41 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -8,6 +8,7 @@ export * from './DialogManagerContext'; export * from './MessageContext'; export * from './MessageBounceContext'; export * from './MessageComposerContext'; +export * from './MessageComposerControllerContext'; export * from './MessageListContext'; export * from './MessageTranslationViewContext'; export * from './ModalContext'; From b176fe484aea7e81d21d753b3fd47003677593ca Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 25 Sep 2026 10:53:39 +0200 Subject: [PATCH 3/6] chore(demo): edit messages inline through a supplied composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An "Edit inline" message action, switched on per message list in Settings > Message Actions, swaps the message for a `MessageComposer` in place. The edit goes into a composer the message owns, supplied through `MessageComposerControllerProvider`, so the channel's own composer - and anything already typed into it - is left alone. The ✕ on the edit preview cancels. Ports the demo from GetStream/stream-chat-react#3249. Co-Authored-By: Claude Opus 5.5 --- examples/vite/src/App.tsx | 2 + examples/vite/src/AppSettings/state.ts | 3 + .../tabs/MessageActions/MessageActionsTab.tsx | 13 ++ .../InlineEditMessage/InlineEditMessage.scss | 4 + .../InlineEditMessage/InlineEditMessage.tsx | 144 ++++++++++++++++++ examples/vite/src/InlineEditMessage/index.ts | 1 + examples/vite/src/index.scss | 1 + 7 files changed, 168 insertions(+) create mode 100644 examples/vite/src/InlineEditMessage/InlineEditMessage.scss create mode 100644 examples/vite/src/InlineEditMessage/InlineEditMessage.tsx create mode 100644 examples/vite/src/InlineEditMessage/index.ts diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index c00aaac0fd..2457bf27ef 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -93,6 +93,7 @@ import { SegmentedReactionsList, } from './CustomMessageUi'; import { ConfigurableMessageActions } from './CustomMessageActions'; +import { InlineEditableMessage } from './InlineEditMessage'; import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx'; import { StreamDebugHandles } from './Debug'; @@ -654,6 +655,7 @@ const App = () => { HeaderStartContent: SidebarToggle, MessageActions: ConfigurableMessageActions, AttachmentSelector: CommandModeAttachmentSelector, + MessageUI: InlineEditableMessage, ...messageUiOverrides, }} > diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 216a282ba8..892661fb9e 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -44,6 +44,7 @@ export type CustomMessageActionToggles = { * *off* so a newly shipped default action appears without a settings migration. */ disabledActionTypes: string[]; + inlineEdit: boolean; markOwnUnread: boolean; viewMessageInfo: boolean; }; @@ -191,6 +192,7 @@ const defaultAppSettingsState: AppSettingsState = { enableOptionConfiguration: false, }, disabledActionTypes: [], + inlineEdit: false, markOwnUnread: false, viewMessageInfo: false, }, @@ -199,6 +201,7 @@ const defaultAppSettingsState: AppSettingsState = { enableOptionConfiguration: false, }, disabledActionTypes: [], + inlineEdit: false, markOwnUnread: false, viewMessageInfo: false, }, diff --git a/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx index d1d4652d8b..00e180074c 100644 --- a/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx @@ -89,6 +89,19 @@ const CUSTOM_ACTION_ROWS: ActionRow[] = [ delete: { enableOptionConfiguration: value }, }), }, + { + comment: ( + <> + Adds an “Edit inline” action that swaps the message + for a MessageComposer in place, supplied through{' '} + MessageComposerControllerProvider. + + ), + id: 'inline-edit', + label: 'Edit inline', + read: (toggles) => toggles.inlineEdit, + write: (toggles, value) => ({ ...toggles, inlineEdit: value }), + }, { comment: 'Mark own messages as unread too.', id: 'mark-own-unread', diff --git a/examples/vite/src/InlineEditMessage/InlineEditMessage.scss b/examples/vite/src/InlineEditMessage/InlineEditMessage.scss new file mode 100644 index 0000000000..8a52dab6f1 --- /dev/null +++ b/examples/vite/src/InlineEditMessage/InlineEditMessage.scss @@ -0,0 +1,4 @@ +.app__inline-edit-message { + padding: 0.5rem 0; + width: 100%; +} diff --git a/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx new file mode 100644 index 0000000000..f6099caf77 --- /dev/null +++ b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx @@ -0,0 +1,144 @@ +import { type ComponentProps, useEffect, useMemo, useState } from 'react'; +import { MessageComposer as MessageComposerController } from 'stream-chat'; +import type { MessageComposerState } from 'stream-chat'; +import { + asDynamicKey, + ContextMenuButton, + defaultMessageActionSet, + MessageUI as DefaultMessageUI, + IconEdit, + MessageActions, + type MessageActionSetItem, + MessageComposer, + MessageComposerControllerProvider, + type MessageUIComponentProps, + useChannel, + useChatContext, + useComponentContext, + useContextMenuContext, + useMessageContext, + useStateStore, + useThreadContext, + useTranslationContext, + WithComponents, +} from 'stream-chat-react'; + +import { useAppSettingsSelector } from '../AppSettings'; +import type { MessageActionSurface } from '../AppSettings'; + +// Next to the built-in edit, so the two ways of editing read as alternatives. +const insertBeforeEdit = ( + actionSet: MessageActionSetItem[], + actionSetItem: MessageActionSetItem, +) => { + const editIndex = actionSet.findIndex((item) => 'type' in item && item.type === 'edit'); + + if (editIndex < 0) return [...actionSet, actionSetItem]; + + return [...actionSet.slice(0, editIndex), actionSetItem, ...actionSet.slice(editIndex)]; +}; + +const editingSelector = (state: MessageComposerState) => ({ + editing: state.editedMessage != null, +}); + +/** + * Swaps a message for a `MessageComposer` in place, editing it through a composer this component + * owns and supplies with `MessageComposerControllerProvider` - so the channel's own composer, and + * whatever the user was typing into it, are left alone. + */ +export const InlineEditableMessage = (props: MessageUIComponentProps) => { + const { client } = useChatContext(); + const channel = useChannel(); + const surface: MessageActionSurface = useThreadContext() ? 'thread' : 'channel'; + const { customMessageActions } = useAppSettingsSelector( + (state) => state.messageActions, + ); + const inlineEditEnabled = customMessageActions[surface].inlineEdit; + const { MessageActions: OuterMessageActions = MessageActions } = useComponentContext(); + + // Drafts off: an edit is not a draft, and the channel's draft must not leak into this composer. + const [editingComposer] = useState( + () => + new MessageComposerController({ + client, + compositionContext: channel, + config: { drafts: { enabled: false } }, + }), + ); + + const { editing } = useStateStore(editingComposer.state, editingSelector); + + // Turning the setting off mid-edit abandons the edit, rather than leaving the message stuck as a + // composer with no way back. + useEffect(() => { + if (!inlineEditEnabled && editing) editingComposer.clear(); + }, [editing, editingComposer, inlineEditEnabled]); + + const MessageActionsWithInlineEdit = useMemo(() => { + const InlineEditAction = () => { + const { closeMenu } = useContextMenuContext(); + const { message } = useMessageContext(); + const { t } = useTranslationContext(); + + return ( + { + editingComposer.initState({ composition: message }); + closeMenu(); + }} + > + {t(asDynamicKey('viteExample.inlineEdit.action.label'), 'Edit inline')} + + ); + }; + + const inlineEditActionSetItem: MessageActionSetItem = { + Component: InlineEditAction, + placement: 'dropdown', + type: 'editInline', + }; + + const Component = (actionsProps: ComponentProps) => { + const messageActionSet = useMemo( + () => + insertBeforeEdit( + actionsProps.messageActionSet ?? defaultMessageActionSet, + inlineEditActionSetItem, + ), + [actionsProps.messageActionSet], + ); + + return ( + + ); + }; + Component.displayName = 'MessageActionsWithInlineEdit'; + return Component; + }, [OuterMessageActions, editingComposer]); + + if (!inlineEditEnabled) return ; + + if (editing) { + return ( + +
+ {/* Cancelling is the ✕ on the composer's edit preview, which clears the composer. */} + +
+
+ ); + } + + return ( + + + + ); +}; diff --git a/examples/vite/src/InlineEditMessage/index.ts b/examples/vite/src/InlineEditMessage/index.ts new file mode 100644 index 0000000000..32f21bf26a --- /dev/null +++ b/examples/vite/src/InlineEditMessage/index.ts @@ -0,0 +1 @@ +export { InlineEditableMessage } from './InlineEditMessage'; diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index ce08ded34e..b9efbab3fc 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -9,6 +9,7 @@ @import url('./AppSettings/AppSettings.scss') layer(stream-app-overrides); @import url('./CustomMessageActions/CustomMessageActions.scss') layer(stream-app-overrides); +@import url('./InlineEditMessage/InlineEditMessage.scss') layer(stream-app-overrides); @import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides); @import url('./AccessibilityNavigation/ReturnToSkipNavigation.scss') layer(stream-app-overrides); From 4f540c231b897cae48c9d5b5a531c14ee9c67d31 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 25 Sep 2026 12:15:03 +0200 Subject: [PATCH 4/6] feat(MessageComposer)!: never clear a supplied composer on unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composer supplied through `MessageComposerControllerProvider` belongs to whoever supplied it, so `MessageComposer` no longer clears it when it unmounts. The owner decides when the composition ends: submitting it, or cancelling it with the ✕ on the edit preview. Its draft is still saved on unmount; `createDraft()` skips edits and composers with drafts disabled. The thread's and the channel's own composers are cleared on unmount as before, after their draft is saved. This replaces the `preventClearingOnUnmount` opt-out, which every inline editor needed, including under React StrictMode, whose dev remount cleared an edit loaded before `MessageComposer` mounted. BREAKING CHANGE: the `preventClearingOnUnmount` prop is removed from `MessageComposer`. Drop it. If you relied on the unmount clearing a composer supplied through `MessageComposerControllerProvider`, call `clear()` on it yourself once your UI is done with it. Co-Authored-By: Claude Opus 5.5 --- ai-docs/ai-migration-v14-v15.md | 8 ++ .../InlineEditMessage/InlineEditMessage.tsx | 2 +- .../MessageComposer/MessageComposer.tsx | 19 +-- .../__tests__/MessageInput.test.tsx | 126 ++++++++++-------- 4 files changed, 91 insertions(+), 64 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index b0d65fedbc..2df09c7222 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -95,6 +95,14 @@ const ReadOnlyActions = () => ( - **Intercept the outgoing request** → register a `sendMessageRequest` handler on `client.config` (also `updateMessageRequest` / `deleteMessageRequest` / `markReadRequest`). `channel.messageOperations` uses it instead of the default request. The `do*Request` props that did this in v14 are removed — see "Per-component request-handler props removed" below. - **Transform the composed message** → register composition middleware on `messageComposer`. +### `MessageComposer` `preventClearingOnUnmount` prop → removed + +`MessageComposer` no longer clears a composer supplied through `MessageComposerControllerProvider` +when it unmounts - that composer belongs to whoever supplied it - so the prop that opted out of the +clearing is gone. Drop it. If you relied on the unmount clearing a supplied composer, call `clear()` +on it yourself once your UI is done with it. The channel's and thread's own composers are still +cleared on unmount, after their draft is saved. + ### `ChatContext.setActiveChannel` → removed There is no `setActiveChannel` on `ChatContext`. Bind a channel by: diff --git a/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx index f6099caf77..d93a5d9d72 100644 --- a/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx +++ b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx @@ -130,7 +130,7 @@ export const InlineEditableMessage = (props: MessageUIComponentProps) => {
{/* Cancelling is the ✕ on the composer's edit preview, which clears the composer. */} - +
); diff --git a/src/components/MessageComposer/MessageComposer.tsx b/src/components/MessageComposer/MessageComposer.tsx index 4ea6b3831a..8858764f1f 100644 --- a/src/components/MessageComposer/MessageComposer.tsx +++ b/src/components/MessageComposer/MessageComposer.tsx @@ -8,7 +8,10 @@ import { useMessageComposerBindings } from './hooks/useMessageComposerBindings'; import type { ComponentContextValue } from '../../context/ComponentContext'; import { useComponentContext } from '../../context/ComponentContext'; import { MessageComposerContextProvider } from '../../context/MessageComposerContext'; -import { DialogManagerProvider } from '../../context'; +import { + DialogManagerProvider, + useMessageComposerControllerContext, +} from '../../context'; import { useStableId } from '../UtilityComponents/useStableId'; import type { LocalMessage } from 'stream-chat'; @@ -72,12 +75,6 @@ export type MessageComposerProps = { * ``` */ shouldSubmit?: (event: React.KeyboardEvent) => boolean; - /** - * Keeps the composer's state when this component unmounts instead of clearing it. For a composer - * the integrator owns - one supplied through `MessageComposerControllerProvider` - where - * unmounting the UI does not end the composition. - */ - preventClearingOnUnmount?: boolean; }; const MessageComposerProvider = (props: PropsWithChildren) => { @@ -91,15 +88,19 @@ const MessageComposerProvider = (props: PropsWithChildren) }); const messageComposer = useMessageComposerController(); + const suppliedComposer = useMessageComposerControllerContext(); useEffect( () => () => { // `createDraft` already skips edits and composers with drafts disabled. const draftSaved = messageComposer.createDraft().catch(console.error); - if (props.preventClearingOnUnmount) return; + // Only the thread's or channel's own composer is emptied here. One supplied through + // `MessageComposerControllerProvider` belongs to whoever supplied it, and so does deciding + // when to clear it - clearing it on unmount would discard an edit the owner still holds (visible in Strict Mode). + if (messageComposer === suppliedComposer) return; draftSaved.finally(() => messageComposer.clear()); }, - [messageComposer, props.preventClearingOnUnmount], + [messageComposer, suppliedComposer], ); useEffect(() => { diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index 69e5975153..2780617a73 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -265,6 +265,7 @@ const renderComponent = async ({ messageComposerController, messageContextOverrides = {}, messageInputProps = {}, + strictMode = false, }: { channelData?: GenerateChannelOptions | GenerateChannelOptions[]; channelProps?: Partial; @@ -277,6 +278,8 @@ const renderComponent = async ({ messageComposerController?: MessageComposerController; messageContextOverrides?: Partial; messageInputProps?: Partial; + /** Renders inside `React.StrictMode`, which in development mounts, unmounts and remounts. */ + strictMode?: boolean; [key: string]: unknown; } = {}) => { let channel = customChannel; @@ -293,43 +296,47 @@ const renderComponent = async ({ let renderResult: RenderResult; + const Root = strictMode ? React.StrictMode : React.Fragment; + await act(() => { renderResult = render( - - ({ - ...defaultChatContext, - channel, - client, - ...chatContextOverrides, - })} - > - - {/* Mirrors what the component provides; this harness uses raw ChatProvider. */} - - - - ({ - ...defaultMessageContextValue, - ...messageContextOverrides, - })} - > - - - - - - - - - - , + + + ({ + ...defaultChatContext, + channel, + client, + ...chatContextOverrides, + })} + > + + {/* Mirrors what the component provides; this harness uses raw ChatProvider. */} + + + + ({ + ...defaultMessageContextValue, + ...messageContextOverrides, + })} + > + + + + + + + + + + + , ); }); @@ -2247,23 +2254,6 @@ describe(`MessageInputFlat`, () => { await waitFor(() => expect(clear).toHaveBeenCalledTimes(1)); }); - it('still saves the draft but keeps the state with preventClearingOnUnmount', async () => { - const { customChannel, customClient } = await setup(); - const createDraft = vi.spyOn(customChannel.messageComposer, 'createDraft'); - const clear = vi.spyOn(customChannel.messageComposer, 'clear'); - const { unmount } = await renderComponent({ - customChannel, - customClient, - messageInputProps: { preventClearingOnUnmount: true }, - }); - - unmount(); - await flushMicrotasks(); - - expect(createDraft).toHaveBeenCalledTimes(1); - expect(clear).not.toHaveBeenCalled(); - }); - it('clears the composer even when saving the draft fails', async () => { const { customChannel, customClient } = await setup(); const error = new Error('draft request failed'); @@ -2278,12 +2268,13 @@ describe(`MessageInputFlat`, () => { expect(consoleError).toHaveBeenCalledWith(error); }); - it('acts on a supplied composer, not the channel one', async () => { + it('saves the draft of a supplied composer but leaves clearing it to its owner', async () => { const { customChannel, customClient } = await setup(); const supplied = new MessageComposerController({ client: customClient, compositionContext: customChannel, }); + const createDraft = vi.spyOn(supplied, 'createDraft'); const clearSupplied = vi.spyOn(supplied, 'clear'); const clearChannel = vi.spyOn(customChannel.messageComposer, 'clear'); const { unmount } = await renderComponent({ @@ -2293,9 +2284,36 @@ describe(`MessageInputFlat`, () => { }); unmount(); + await flushMicrotasks(); - await waitFor(() => expect(clearSupplied).toHaveBeenCalledTimes(1)); + expect(createDraft).toHaveBeenCalledTimes(1); + expect(clearSupplied).not.toHaveBeenCalled(); expect(clearChannel).not.toHaveBeenCalled(); }); + + // The case that made clearing a supplied composer a trap: an edit is loaded before the composer + // mounts, and StrictMode's development remount runs the unmount cleanup over it straight away. + it('keeps an edit loaded into a supplied composer through a StrictMode remount', async () => { + const { customChannel, customClient } = await setup(); + const supplied = new MessageComposerController({ + client: customClient, + compositionContext: customChannel, + config: { drafts: { enabled: false } }, + }); + supplied.initState({ composition: mainListMessage }); + + await renderComponent({ + customChannel, + customClient, + messageComposerController: supplied, + strictMode: true, + }); + await flushMicrotasks(); + + expect(supplied.editedMessage?.id).toBe(mainListMessage.id); + expect(await screen.findByPlaceholderText(inputPlaceholder)).toHaveValue( + mainListMessage.text, + ); + }); }); }); From c60bb5ed7961ba032d64069a89fe85d01d720880 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 25 Sep 2026 12:16:22 +0200 Subject: [PATCH 5/6] chore(demo): keep an unfinished inline edit in the composer cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline editor's composer now lives in `client.messageComposerCache` under `MessageComposer.constructTag(message)`, with the message as its composition context. It is created and stored when the user enters edit mode; a row nobody edits creates nothing. Every Edit Inline starts again from the message. Because `MessageComposer` never clears a supplied composer on unmount, an unfinished edit survives the row unmounting - leaving the channel, or scrolling the row away in a virtualized list - and the editor comes back on return. The ✕ on the edit preview discards it. Co-Authored-By: Claude Opus 5.5 --- .../InlineEditMessage/InlineEditMessage.tsx | 62 ++++++++++++------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx index d93a5d9d72..01b5ea18b4 100644 --- a/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx +++ b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx @@ -1,6 +1,6 @@ -import { type ComponentProps, useEffect, useMemo, useState } from 'react'; +import { type ComponentProps, useCallback, useEffect, useMemo, useState } from 'react'; import { MessageComposer as MessageComposerController } from 'stream-chat'; -import type { MessageComposerState } from 'stream-chat'; +import type { LocalMessage, MessageComposerState } from 'stream-chat'; import { asDynamicKey, ContextMenuButton, @@ -12,7 +12,6 @@ import { MessageComposer, MessageComposerControllerProvider, type MessageUIComponentProps, - useChannel, useChatContext, useComponentContext, useContextMenuContext, @@ -43,13 +42,14 @@ const editingSelector = (state: MessageComposerState) => ({ }); /** - * Swaps a message for a `MessageComposer` in place, editing it through a composer this component - * owns and supplies with `MessageComposerControllerProvider` - so the channel's own composer, and - * whatever the user was typing into it, are left alone. + * Swaps a message for a `MessageComposer` in place, editing it through a composer of its own + * supplied with `MessageComposerControllerProvider` - so the channel's own composer, and whatever + * the user was typing into it, are left alone. That composer is kept in the client's composer cache + * under the message's tag, so an unfinished edit outlives this row. */ export const InlineEditableMessage = (props: MessageUIComponentProps) => { const { client } = useChatContext(); - const channel = useChannel(); + const { message } = useMessageContext(); const surface: MessageActionSurface = useThreadContext() ? 'thread' : 'channel'; const { customMessageActions } = useAppSettingsSelector( (state) => state.messageActions, @@ -57,22 +57,39 @@ export const InlineEditableMessage = (props: MessageUIComponentProps) => { const inlineEditEnabled = customMessageActions[surface].inlineEdit; const { MessageActions: OuterMessageActions = MessageActions } = useComponentContext(); - // Drafts off: an edit is not a draft, and the channel's draft must not leak into this composer. - const [editingComposer] = useState( - () => - new MessageComposerController({ - client, - compositionContext: channel, - config: { drafts: { enabled: false } }, - }), + // Only looks - a row nobody edits creates nothing. A composer found here holding an edit is one + // left unfinished while this row was unmounted, so the editor comes straight back. + const [editingComposer, setEditingComposer] = useState(() => + client.messageComposerCache.peek(MessageComposerController.constructTag(message)), ); - const { editing } = useStateStore(editingComposer.state, editingSelector); + const { editing } = useStateStore(editingComposer?.state, editingSelector) ?? { + editing: false, + }; + + // Entering edit mode is where the composer is created if missing, and stored at once. + const startEditing = useCallback( + (messageToEdit: LocalMessage) => { + const tag = MessageComposerController.constructTag(messageToEdit); + const composer = + client.messageComposerCache.peek(tag) ?? + // Drafts off: an edit is not a draft. + new MessageComposerController({ + client, + compositionContext: messageToEdit, + config: { drafts: { enabled: false } }, + }); + client.messageComposerCache.add(tag, composer); + composer.initState({ composition: messageToEdit }); + setEditingComposer(composer); + }, + [client], + ); // Turning the setting off mid-edit abandons the edit, rather than leaving the message stuck as a // composer with no way back. useEffect(() => { - if (!inlineEditEnabled && editing) editingComposer.clear(); + if (!inlineEditEnabled && editing) editingComposer?.clear(); }, [editing, editingComposer, inlineEditEnabled]); const MessageActionsWithInlineEdit = useMemo(() => { @@ -90,7 +107,7 @@ export const InlineEditableMessage = (props: MessageUIComponentProps) => { className='str-chat__message-actions-list-item-button' Icon={IconEdit} onClick={() => { - editingComposer.initState({ composition: message }); + startEditing(message); closeMenu(); }} > @@ -121,15 +138,18 @@ export const InlineEditableMessage = (props: MessageUIComponentProps) => { }; Component.displayName = 'MessageActionsWithInlineEdit'; return Component; - }, [OuterMessageActions, editingComposer]); + }, [OuterMessageActions, startEditing]); if (!inlineEditEnabled) return ; - if (editing) { + if (editing && editingComposer) { return (
- {/* Cancelling is the ✕ on the composer's edit preview, which clears the composer. */} + {/* The composer outlives this editor: leaving the channel, or scrolling the row away in a + virtualized message list, unmounts the editor, and `MessageComposer` never clears a + supplied composer on unmount - so the unfinished edit is still there on return. + Cancelling is the ✕ on the edit preview, which clears it on purpose. */}
From b5bdc73fbb00a6eb392030faaca711ede5256ea1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 25 Sep 2026 12:59:45 +0200 Subject: [PATCH 6/6] chore(demo): define the inline-edit action at module level The "Edit inline" action and the `MessageActions` wrapper that added it were component types created inside `useMemo`, so a change to one of the memo's dependencies gave React a new type and remounted the actions menu instead of re-rendering it. `InlineEditMessageAction` is now a module-level component, and `ConfigurableMessageActions` adds it next to the built-in edit, alongside the demo's other custom actions, while the setting is on. It starts editing its row through `InlineEditContext`, which `InlineEditableMessage` provides. The `WithComponents` override is gone. Co-Authored-By: Claude Opus 5.5 --- .../ConfigurableMessageActions.tsx | 22 +++- .../InlineEditMessage/InlineEditMessage.tsx | 109 ++++++++---------- examples/vite/src/InlineEditMessage/index.ts | 2 +- 3 files changed, 67 insertions(+), 66 deletions(-) diff --git a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx index a393f31c3a..e23aeecfa3 100644 --- a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx +++ b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx @@ -34,6 +34,7 @@ import { import { useAppSettingsSelector } from '../AppSettings'; import type { MessageActionSurface } from '../AppSettings'; +import { InlineEditMessageAction } from '../InlineEditMessage'; import { MessageInfoPromptDialog, messageInfoPromptDialogId, @@ -286,7 +287,11 @@ const CustomViewMessageInfoAction = () => { ); }; -type SupportedCustomMessageActionType = 'delete' | 'markOwnUnread' | 'viewMessageInfo'; +type SupportedCustomMessageActionType = + | 'delete' + | 'editInline' + | 'markOwnUnread' + | 'viewMessageInfo'; type CustomMessageActionOverrideSpec = { actionSetItem: MessageActionSetItem; @@ -379,6 +384,16 @@ export const ConfigurableMessageActions = ( }, mode: 'replace', }, + // Next to the built-in edit, so the two ways of editing read as alternatives. + editInline: { + actionSetItem: { + Component: InlineEditMessageAction, + placement: 'dropdown', + type: 'editInline', + }, + insertBeforeType: 'edit', + mode: 'append', + }, markOwnUnread: { actionSetItem: { Component: CustomMarkOwnUnreadMessageAction, @@ -404,6 +419,10 @@ export const ConfigurableMessageActions = ( ...actionOverrides.delete, enabled: customDeleteEnabled, }, + { + ...actionOverrides.editInline, + enabled: surfaceActions.inlineEdit, + }, { ...actionOverrides.markOwnUnread, enabled: surfaceActions.markOwnUnread, @@ -430,6 +449,7 @@ export const ConfigurableMessageActions = ( }, [ customDeleteEnabled, surfaceActions.disabledActionTypes, + surfaceActions.inlineEdit, surfaceActions.markOwnUnread, surfaceActions.viewMessageInfo, props.messageActionSet, diff --git a/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx index 01b5ea18b4..4c30210206 100644 --- a/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx +++ b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx @@ -1,40 +1,68 @@ -import { type ComponentProps, useCallback, useEffect, useMemo, useState } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { MessageComposer as MessageComposerController } from 'stream-chat'; import type { LocalMessage, MessageComposerState } from 'stream-chat'; import { asDynamicKey, ContextMenuButton, - defaultMessageActionSet, MessageUI as DefaultMessageUI, IconEdit, - MessageActions, - type MessageActionSetItem, MessageComposer, MessageComposerControllerProvider, type MessageUIComponentProps, useChatContext, - useComponentContext, useContextMenuContext, useMessageContext, useStateStore, useThreadContext, useTranslationContext, - WithComponents, } from 'stream-chat-react'; import { useAppSettingsSelector } from '../AppSettings'; import type { MessageActionSurface } from '../AppSettings'; -// Next to the built-in edit, so the two ways of editing read as alternatives. -const insertBeforeEdit = ( - actionSet: MessageActionSetItem[], - actionSetItem: MessageActionSetItem, -) => { - const editIndex = actionSet.findIndex((item) => 'type' in item && item.type === 'edit'); +type InlineEditContextValue = { + startEditing: (message: LocalMessage) => void; +}; - if (editIndex < 0) return [...actionSet, actionSetItem]; +// Lets the "Edit inline" action, rendered deep in the message's actions menu, start editing the +// row it belongs to. +const InlineEditContext = createContext(undefined); - return [...actionSet.slice(0, editIndex), actionSetItem, ...actionSet.slice(editIndex)]; +/** + * The "Edit inline" message action. `ConfigurableMessageActions` adds it to the action set when + * the setting is on; it only works inside an `InlineEditableMessage`, which provides the context. + */ +export const InlineEditMessageAction = () => { + const inlineEdit = useContext(InlineEditContext); + const { closeMenu } = useContextMenuContext(); + const { message } = useMessageContext(); + const { t } = useTranslationContext(); + + if (!inlineEdit) return null; + + return ( + { + inlineEdit.startEditing(message); + closeMenu(); + }} + > + {t(asDynamicKey('viteExample.inlineEdit.action.label'), 'Edit inline')} + + ); }; const editingSelector = (state: MessageComposerState) => ({ @@ -55,7 +83,6 @@ export const InlineEditableMessage = (props: MessageUIComponentProps) => { (state) => state.messageActions, ); const inlineEditEnabled = customMessageActions[surface].inlineEdit; - const { MessageActions: OuterMessageActions = MessageActions } = useComponentContext(); // Only looks - a row nobody edits creates nothing. A composer found here holding an edit is one // left unfinished while this row was unmounted, so the editor comes straight back. @@ -92,53 +119,7 @@ export const InlineEditableMessage = (props: MessageUIComponentProps) => { if (!inlineEditEnabled && editing) editingComposer?.clear(); }, [editing, editingComposer, inlineEditEnabled]); - const MessageActionsWithInlineEdit = useMemo(() => { - const InlineEditAction = () => { - const { closeMenu } = useContextMenuContext(); - const { message } = useMessageContext(); - const { t } = useTranslationContext(); - - return ( - { - startEditing(message); - closeMenu(); - }} - > - {t(asDynamicKey('viteExample.inlineEdit.action.label'), 'Edit inline')} - - ); - }; - - const inlineEditActionSetItem: MessageActionSetItem = { - Component: InlineEditAction, - placement: 'dropdown', - type: 'editInline', - }; - - const Component = (actionsProps: ComponentProps) => { - const messageActionSet = useMemo( - () => - insertBeforeEdit( - actionsProps.messageActionSet ?? defaultMessageActionSet, - inlineEditActionSetItem, - ), - [actionsProps.messageActionSet], - ); - - return ( - - ); - }; - Component.displayName = 'MessageActionsWithInlineEdit'; - return Component; - }, [OuterMessageActions, startEditing]); + const inlineEditContextValue = useMemo(() => ({ startEditing }), [startEditing]); if (!inlineEditEnabled) return ; @@ -157,8 +138,8 @@ export const InlineEditableMessage = (props: MessageUIComponentProps) => { } return ( - + - + ); }; diff --git a/examples/vite/src/InlineEditMessage/index.ts b/examples/vite/src/InlineEditMessage/index.ts index 32f21bf26a..c8abee949e 100644 --- a/examples/vite/src/InlineEditMessage/index.ts +++ b/examples/vite/src/InlineEditMessage/index.ts @@ -1 +1 @@ -export { InlineEditableMessage } from './InlineEditMessage'; +export { InlineEditableMessage, InlineEditMessageAction } from './InlineEditMessage';