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/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/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.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..4c30210206 --- /dev/null +++ b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx @@ -0,0 +1,145 @@ +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, + MessageUI as DefaultMessageUI, + IconEdit, + MessageComposer, + MessageComposerControllerProvider, + type MessageUIComponentProps, + useChatContext, + useContextMenuContext, + useMessageContext, + useStateStore, + useThreadContext, + useTranslationContext, +} from 'stream-chat-react'; + +import { useAppSettingsSelector } from '../AppSettings'; +import type { MessageActionSurface } from '../AppSettings'; + +type InlineEditContextValue = { + startEditing: (message: LocalMessage) => void; +}; + +// Lets the "Edit inline" action, rendered deep in the message's actions menu, start editing the +// row it belongs to. +const InlineEditContext = createContext(undefined); + +/** + * 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) => ({ + editing: state.editedMessage != null, +}); + +/** + * 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 { message } = useMessageContext(); + const surface: MessageActionSurface = useThreadContext() ? 'thread' : 'channel'; + const { customMessageActions } = useAppSettingsSelector( + (state) => state.messageActions, + ); + const inlineEditEnabled = customMessageActions[surface].inlineEdit; + + // 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) ?? { + 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(); + }, [editing, editingComposer, inlineEditEnabled]); + + const inlineEditContextValue = useMemo(() => ({ startEditing }), [startEditing]); + + if (!inlineEditEnabled) return ; + + if (editing && editingComposer) { + return ( + +
+ {/* 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. */} + +
+
+ ); + } + + return ( + + + + ); +}; diff --git a/examples/vite/src/InlineEditMessage/index.ts b/examples/vite/src/InlineEditMessage/index.ts new file mode 100644 index 0000000000..c8abee949e --- /dev/null +++ b/examples/vite/src/InlineEditMessage/index.ts @@ -0,0 +1 @@ +export { InlineEditableMessage, InlineEditMessageAction } 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); diff --git a/src/components/MessageComposer/MessageComposer.tsx b/src/components/MessageComposer/MessageComposer.tsx index 6bd0593f6e..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'; @@ -85,12 +88,19 @@ const MessageComposerProvider = (props: PropsWithChildren) }); const messageComposer = useMessageComposerController(); + const suppliedComposer = useMessageComposerControllerContext(); useEffect( () => () => { - messageComposer.createDraft().finally(() => messageComposer.clear()); + // `createDraft` already skips edits and composers with drafts disabled. + const draftSaved = messageComposer.createDraft().catch(console.error); + // 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], + [messageComposer, suppliedComposer], ); useEffect(() => { 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..2780617a73 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,8 +262,10 @@ const renderComponent = async ({ customClient, customUser, messageActionsProps = {}, + messageComposerController, messageContextOverrides = {}, messageInputProps = {}, + strictMode = false, }: { channelData?: GenerateChannelOptions | GenerateChannelOptions[]; channelProps?: Partial; @@ -264,8 +275,11 @@ const renderComponent = async ({ customClient?: StreamChat; customUser?: UserResponse; messageActionsProps?: Partial; + 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; @@ -282,39 +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, + })} + > + + + + + + + + + + + , ); }); @@ -2049,6 +2071,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); @@ -2177,4 +2236,84 @@ 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('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('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({ + customChannel, + customClient, + messageComposerController: supplied, + }); + + unmount(); + await flushMicrotasks(); + + 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, + ); + }); + }); }); 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/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/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/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, ], 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';