diff --git a/examples/E2ETestApp/package.json b/examples/E2ETestApp/package.json index 385167342b..4141218c32 100644 --- a/examples/E2ETestApp/package.json +++ b/examples/E2ETestApp/package.json @@ -23,7 +23,7 @@ "react-native-svg": "^15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "^0.13.0", - "stream-chat": "^10.0.0-rc.28", + "stream-chat": "^10.0.0-rc.30", "stream-chat-react-native": "workspace:^" }, "devDependencies": { diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index 58bbd15f7b..ae780d2eab 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -29,7 +29,7 @@ "react-native-svg": "15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "0.13.0", - "stream-chat": "^10.0.0-rc.28", + "stream-chat": "^10.0.0-rc.30", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^", "victory-native": "^42.0.1" diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index ab99ce1be1..83a850f1c8 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.2.2", "react-native-web": "~0.21.4", "react-native-worklets": "0.13.0", - "stream-chat": "^10.0.0-rc.28", + "stream-chat": "^10.0.0-rc.30", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index d1134a3882..2afea71076 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -65,7 +65,7 @@ "react-native-teleport": "^1.2.2", "react-native-video": "^6.19.3", "react-native-worklets": "^0.13.0", - "stream-chat": "^10.0.0-rc.28", + "stream-chat": "^10.0.0-rc.30", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index 502c982763..ac553e816b 100644 --- a/package/package.json +++ b/package/package.json @@ -85,7 +85,7 @@ "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", "react-syntax-highlighter": "^16.1.0", - "stream-chat": "^10.0.0-rc.28", + "stream-chat": "^10.0.0-rc.30", "use-sync-external-store": "^1.7.0", "zod": "^4.1.12" }, diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 62301814da..75c3573e6e 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -104,6 +104,7 @@ const ChannelWithContext = (props: PropsWithChildren) = {...shellProps} channel={channel} hasPendingInitialTargetLoad={hasPendingInitialTargetLoad} + initializeOnMount={initializeOnMount} > {children} diff --git a/package/src/components/Channel/ChannelShell.tsx b/package/src/components/Channel/ChannelShell.tsx index 4eb44e7f37..8ec4b0465b 100644 --- a/package/src/components/Channel/ChannelShell.tsx +++ b/package/src/components/Channel/ChannelShell.tsx @@ -15,6 +15,7 @@ import { useCreateChannelContext } from './hooks/useCreateChannelContext'; import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessageInputContext'; import { useCreateMessagesContext } from './hooks/useCreateMessagesContext'; import { useCreateOwnCapabilitiesContext } from './hooks/useCreateOwnCapabilitiesContext'; +import { useJumpToLatestOnOpen } from './hooks/useJumpToLatestOnOpen'; import { AttachmentPickerContextValue, @@ -208,6 +209,8 @@ export type ChannelConfigProps = Partial< type ChannelShellProps = ChannelConfigProps & { channel: Channel; hasPendingInitialTargetLoad: () => boolean; + /** ``'s `initializeOnMount`: when false, the app places the list's pages itself. */ + initializeOnMount?: boolean; /** Set by ``: the list and composer below show this thread's replies. */ thread?: Thread; }; @@ -228,6 +231,13 @@ const channelStatusSelector = (state: ChannelDataState & ChannelLifecycleState) pendingDisposal: state.pendingDisposal, }); +// Renders nothing. It sits under the providers below so its hook can read the +// channel and thread context this component provides. +const JumpToLatestOnOpen = () => { + useJumpToLatestOnOpen(); + return null; +}; + /** * The providers a message list and composer need, shared by `` and ``, which each * own their lifecycle (watching, loading, marking read) and render this around their children. @@ -288,6 +298,7 @@ export const ChannelShell = (props: PropsWithChildren) => { hasFilePicker = isDocumentPickerAvailable(), hasImagePicker = isImagePickerAvailable() || isImageMediaLibraryAvailable(), hasPendingInitialTargetLoad, + initializeOnMount = true, allowDateSeparatorForSystemMessages = false, hideDateSeparators = false, hideStickyDateHeader = false, @@ -508,6 +519,7 @@ export const ChannelShell = (props: PropsWithChildren) => { + {initializeOnMount ? : null} {children} {shouldRenderAttachmentPicker ? : null} diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index a4cc13291e..72b0791b2e 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -796,6 +796,108 @@ describe('Channel initial load useEffect', () => { }); }); + describe('reopening a channel left on an older page', () => { + const OLDER_PAGE_START = new Date('2020-01-01T00:00:00.000Z').getTime(); + + // A watched channel whose newest messages are loaded, but whose list shows an older page, as it + // does after a jump to a quoted, pinned or searched message. + const setUpChannelOnOlderPage = async () => { + const newestMessages = Array.from({ length: 10 }, (_, i) => + generateMessage({ id: `newest-${i}` }), + ); + const mockedChannel = generateChannelResponse({ messages: newestMessages }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const olderMessages = Array.from({ length: 5 }, (_, i) => + generateMessage({ id: `older-${i}`, timestamp: OLDER_PAGE_START + i * 1000 }), + ); + channel.messagePaginator.ingestPage({ + page: olderMessages, + policy: 'strict-overlap-only', + setActive: true, + }); + expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false); + + return channel; + }; + + it('shows the newest messages again', async () => { + const channel = await setUpChannelOnOlderPage(); + + renderComponent({ channel, markReadOnMount: false }); + + await waitFor(() => expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(true)); + expect(channel.messagePaginator.items?.map((message) => message.id)).toContain('newest-9'); + }); + + it('keeps a jump the integrator already made to a loaded message', async () => { + const channel = await setUpChannelOnOlderPage(); + // A deep link from the screen: the target is loaded, so the jump lands at once. + await channel.messagePaginator.jumpToMessage('older-2', { focusReason: 'jump-to-message' }); + const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot'); + + renderComponent({ channel, markReadOnMount: false }); + + await waitFor(() => expect(seedSpy).toHaveBeenCalled()); + expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false); + expect(channel.messagePaginator.messageFocusSignal.getLatestValue().signal?.messageId).toBe( + 'older-2', + ); + }); + + it('leaves a jump that is still in flight alone', async () => { + const channel = await setUpChannelOnOlderPage(); + // A deep link to a message that is not loaded: its query is in flight. + jest.spyOn(channel.messagePaginator, 'isLoading', 'get').mockReturnValue(true); + const jumpToTheLatestMessageSpy = jest.spyOn( + channel.messagePaginator, + 'jumpToTheLatestMessage', + ); + const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot'); + + renderComponent({ channel, markReadOnMount: false }); + + await waitFor(() => expect(seedSpy).toHaveBeenCalled()); + expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled(); + }); + + it('leaves the list alone when the app places its pages itself', async () => { + const channel = await setUpChannelOnOlderPage(); + const jumpToTheLatestMessageSpy = jest.spyOn( + channel.messagePaginator, + 'jumpToTheLatestMessage', + ); + + renderComponent({ channel, initializeOnMount: false, markReadOnMount: false }); + + expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled(); + expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false); + }); + + it('leaves the jump to the first unread message to the initial load', async () => { + const channel = await setUpChannelOnOlderPage(); + jest.spyOn(channel, 'countUnread').mockImplementation(() => 3); + const jumpToTheFirstUnreadMessageSpy = jest + .spyOn(channel.messagePaginator, 'jumpToTheFirstUnreadMessage') + .mockResolvedValue(undefined as never); + const jumpToTheLatestMessageSpy = jest.spyOn( + channel.messagePaginator, + 'jumpToTheLatestMessage', + ); + + renderComponent({ + channel, + initialScrollToFirstUnreadMessage: true, + markReadOnMount: false, + }); + + await waitFor(() => expect(jumpToTheFirstUnreadMessageSpy).toHaveBeenCalledTimes(1)); + expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled(); + }); + }); + describe('message page size', () => { const mountChannel = async () => { const mockedChannel = generateChannelResponse({ messages: [] }); diff --git a/package/src/components/Channel/hooks/useJumpToLatestOnOpen.ts b/package/src/components/Channel/hooks/useJumpToLatestOnOpen.ts new file mode 100644 index 0000000000..48f5613796 --- /dev/null +++ b/package/src/components/Channel/hooks/useJumpToLatestOnOpen.ts @@ -0,0 +1,42 @@ +import { useLayoutEffect } from 'react'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; +import { useMessagePaginator } from '../../../hooks/useMessagePaginator'; + +/** + * Shows the newest messages when a channel or thread is opened on an older page. Its paginator + * outlives the screen, so it keeps the page it was left on, such as one after a jump to a quoted, + * pinned or searched message. Runs before the first paint, so the old page never flashes. + * + * Stays out of the way of a jump that already landed or is in flight (a deep link from the screen) + * and of the jump to the first unread message. Runs once per channel or thread: its dependencies + * are stable, so nothing that changes while it is open moves the list. + * + * `` and `` already run it, unless ``. Call it + * from a component inside `` or `` only when the app opted out that way and still + * wants the newest messages shown on open. + */ +export const useJumpToLatestOnOpen = () => { + const { channel, hasPendingInitialTargetLoad } = useChannelContext(); + const { thread } = useThreadContext(); + const paginator = useMessagePaginator(); + + useLayoutEffect(() => { + // A channel that is not loaded yet, or a draft, gets its first page from `` itself. + const isChannelLoading = !thread && (!channel.initialized || channel.isProvisional); + if ( + isChannelLoading || + !paginator.items?.length || + paginator.isActiveIntervalAtHead || + paginator.isLoading || + hasPendingInitialTargetLoad?.() + ) { + return; + } + + paginator + .jumpToTheLatestMessage() + .catch((error: unknown) => console.warn('Jumping to the latest message failed:', error)); + }, [channel, hasPendingInitialTargetLoad, paginator, thread]); +}; diff --git a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx index d7e19f59ab..edbc4de0c6 100644 --- a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -1154,6 +1154,34 @@ describe('ChannelList', () => { expect(querySpy).not.toHaveBeenCalled(); }); + it('loads the first page when an event put a channel in the list before it was ever queried', async () => { + const seededChannel = chatClient.channel(testChannel1.channel.type, testChannel1.channel.id); + // What `message.new` / `member.updated` do for a channel matching a list nobody queried yet. + chatClient.channelManager.ingestChannel(seededChannel); + expect(props.paginator.items?.map((channel) => channel.id)).toEqual([ + testChannel1.channel.id, + ]); + expect(props.paginator.isInitialized).toBe(false); + + useMockedApis(chatClient, [queryChannelsApi([testChannel2, testChannel1])]); + const querySpy = jest.spyOn(chatClient, 'queryChannels'); + render( + + + + + , + ); + + // The seeded channel does not count as a loaded first page, so the list queries on mount. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(querySpy).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByTestId(testChannel2.channel.id)).toBeTruthy()); + expect(props.paginator.isInitialized).toBe(true); + }); + it('leaves the paginator registered and loaded when it unmounts', async () => { useMockedApis(chatClient, [queryChannelsApi([testChannel1])]); diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index f64cc151b7..dad4468788 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -95,7 +95,7 @@ export const usePaginatedChannels = ({ paginator }: { paginator: ChannelPaginato // new paginator, and after `client.disconnectUser` resets it. A paginator that already has // channels is shown as is, and a load in flight is left alone. const userId = useClientUserId(client); - const isUnloaded = channels === undefined; + const isUnloaded = channels === undefined || !paginator.isInitialized; useEffect(() => { if (!isUnloaded || !userId || paginator.isLoading) return; reloadList(); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index ef64b197bc..62bf9ee67b 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { FlatList, StyleSheet } from 'react-native'; @@ -8,6 +8,9 @@ import type { LocalMessage, StreamChat as StreamChatClient, UserResponse } from import { msToNs, Thread } from 'stream-chat'; import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import { useMessageComposerAPIContext } from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; @@ -22,6 +25,7 @@ import { getTestClientWithUser } from '../../../mock-builders/mock'; import { primitives } from '../../../theme'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; +import { MessageComposer } from '../../MessageInput/MessageComposer'; import { Thread as ThreadComponent } from '../../Thread/Thread'; import { SCROLL_TO_BOTTOM_ACCESSIBILITY_ACTION_NAME } from '../hooks/useScrollToBottomAccessibilityAction'; @@ -1290,12 +1294,12 @@ describe('MessageList in a thread', () => { generateMessage({ parent_id: parentMessage.id, text: `reply-${i}` }), ), ) as LocalMessage[]; + // Seeded as the thread seeds `latest_replies`: one complete page, which is the newest one. act(() => - threadInstance.messagePaginator.state.partialNext({ - hasMoreHead: false, - hasMoreTail: false, - isLoading: false, - items: replies, + threadInstance.messagePaginator.setItems({ + valueOrFactory: replies, + isFirstPage: true, + isLastPage: true, }), ); @@ -1413,3 +1417,82 @@ describe('MessageList in a thread', () => { expect(queryAllByLabelText('Inline unread indicator')).toHaveLength(0); }); }); + +describe('MessageList optimistic edit', () => { + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + // A UI renders `i18n._text` in place of `text`, so an optimistic edit that kept the + // translations of the old text showed the "Edited" label over the old message until the server + // responded (or, offline, until the edit was replayed). + it('shows an edit of an auto-translated message before the request resolves', async () => { + const me = generateUser({ id: 'testID' }); + const mine = generateMessage({ + i18n: { en_text: 'before edit', es_text: 'antes de editar', language: 'en' }, + text: 'before edit', + user: me, + }); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: me })], + messages: [mine], + }); + mine.cid = mockedChannel.channel.cid; + + const chatClient = await getTestClientWithUser(me); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + // Never settles, so whatever is on screen is the optimistic state. + const updateMessage = jest + .spyOn(chatClient, 'updateMessage') + .mockImplementation(() => new Promise(() => undefined)); + + let startEditing: (message: LocalMessage) => void = () => undefined; + let submit: () => Promise = () => Promise.resolve(); + let composer: ReturnType | undefined; + const EditDriver = () => { + const { setEditingState } = useMessageComposerAPIContext(); + const { sendMessage } = useMessageInputContext(); + const messageComposer = useMessageComposer(); + useEffect(() => { + startEditing = setEditingState; + submit = sendMessage; + composer = messageComposer; + }); + return null; + }; + + const { getByTestId } = render( + + + + + + + + + , + ); + await waitFor(() => + expect( + within(getByTestId(`message-list-item-${mine.id}`)).getByText('before edit'), + ).toBeTruthy(), + ); + + act(() => startEditing(channel.messagePaginator.getItem(mine.id) as LocalMessage)); + await waitFor(() => expect(composer?.editedMessage?.id).toBe(mine.id)); + act(() => composer?.textComposer.setText('after edit')); + act(() => { + void submit(); + }); + + await waitFor(() => expect(updateMessage).toHaveBeenCalled()); + await waitFor(() => { + const row = within(getByTestId(`message-list-item-${mine.id}`)); + expect(row.getByText('after edit')).toBeTruthy(); + expect(row.queryByText('before edit')).toBeNull(); + }); + }, 15000); +}); diff --git a/package/src/components/Thread/__tests__/Thread.test.tsx b/package/src/components/Thread/__tests__/Thread.test.tsx index 319d60b754..d5ba6aab2f 100644 --- a/package/src/components/Thread/__tests__/Thread.test.tsx +++ b/package/src/components/Thread/__tests__/Thread.test.tsx @@ -586,4 +586,89 @@ describe('Thread lifecycle', () => { expect(markRead.mock.calls.every(([options]) => options?.thread_id)).toBe(true); }); }); + + describe('reopening a thread left on an older page', () => { + const OLDER_PAGE_START = new Date('2020-01-01T00:00:00.000Z').getTime(); + + // A thread whose newest replies are loaded, but whose list shows an older page, as it does + // after a jump to a quoted or searched reply. + const setUpThreadOnOlderPage = async () => { + const testChannel = await watchChannel(); + const parentMessage = testChannel.state.formatMessage(generateMessage({ user })); + const threadInstance = new ThreadClass({ + channel: testChannel, + client: chatClient, + parentMessage, + }); + jest.spyOn(threadInstance, 'reload').mockResolvedValue(undefined); + jest.spyOn(threadInstance.messagePaginator, 'toTail').mockResolvedValue(undefined); + + threadInstance.messagePaginator.setItems({ + valueOrFactory: Array.from({ length: 10 }, (_, i) => + generateMessage({ id: `newest-${i}`, parent_id: parentMessage.id }), + ), + isFirstPage: true, + isLastPage: true, + }); + threadInstance.messagePaginator.ingestPage({ + page: Array.from({ length: 5 }, (_, i) => + generateMessage({ + id: `older-${i}`, + parent_id: parentMessage.id, + timestamp: OLDER_PAGE_START + i * 1000, + }), + ), + policy: 'strict-overlap-only', + setActive: true, + }); + expect(threadInstance.messagePaginator.isActiveIntervalAtHead).toBe(false); + + return threadInstance; + }; + + it('shows the newest replies again', async () => { + const threadInstance = await setUpThreadOnOlderPage(); + + openThread(threadInstance); + + await waitFor(() => + expect(threadInstance.messagePaginator.isActiveIntervalAtHead).toBe(true), + ); + expect(threadInstance.messagePaginator.items?.map((reply) => reply.id)).toContain('newest-9'); + }); + + it('keeps a jump the integrator already made to a loaded reply', async () => { + const threadInstance = await setUpThreadOnOlderPage(); + // A deep link from the screen: the target is loaded, so the jump lands at once. + await threadInstance.messagePaginator.jumpToMessage('older-2', { + focusReason: 'jump-to-message', + }); + const jumpToTheLatestMessageSpy = jest.spyOn( + threadInstance.messagePaginator, + 'jumpToTheLatestMessage', + ); + + openThread(threadInstance); + + expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled(); + expect(threadInstance.messagePaginator.isActiveIntervalAtHead).toBe(false); + expect( + threadInstance.messagePaginator.messageFocusSignal.getLatestValue().signal?.messageId, + ).toBe('older-2'); + }); + + it('leaves a jump that is still in flight alone', async () => { + const threadInstance = await setUpThreadOnOlderPage(); + // A deep link to a reply that is not loaded: its query is in flight. + jest.spyOn(threadInstance.messagePaginator, 'isLoading', 'get').mockReturnValue(true); + const jumpToTheLatestMessageSpy = jest.spyOn( + threadInstance.messagePaginator, + 'jumpToTheLatestMessage', + ); + + openThread(threadInstance); + + expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 24ea0a7ea2..b4af64478c 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -34,6 +34,7 @@ export * from './Channel/hooks/useCreateChannelContext'; export * from './Channel/hooks/useCreateInputMessageInputContext'; export * from './Channel/hooks/useCreateMessagesContext'; export * from './Channel/hooks/useCreateThreadContext'; +export * from './Channel/hooks/useJumpToLatestOnOpen'; export * from './ChannelList/ChannelList'; export * from './ChannelList/ChannelListFooterLoadingIndicator'; diff --git a/package/src/utils/__tests__/setupCommandUIMiddlewares.test.ts b/package/src/utils/__tests__/setupCommandUIMiddlewares.test.ts new file mode 100644 index 0000000000..073f83d8e2 --- /dev/null +++ b/package/src/utils/__tests__/setupCommandUIMiddlewares.test.ts @@ -0,0 +1,49 @@ +import type { MessageComposer } from 'stream-chat'; + +import { initiateClientWithChannels } from '../../mock-builders/api/initiateClientWithChannels'; +import { setupCommandUIMiddlewares } from '../setupCommandUIMiddlewares'; + +const countMiddleware = (composer: MessageComposer) => { + const count = (middleware: ReadonlyArray<{ id: string }>, id: string) => + middleware.filter((md) => md.id === id).length; + + return { + composition: count( + composer.compositionMiddlewareExecutor.installedMiddleware, + 'stream-io/message-composer-middleware/command-string-injection', + ), + draftComposition: count( + composer.draftCompositionMiddlewareExecutor.installedMiddleware, + 'stream-io/message-composer-middleware/draft-command-string-injection', + ), + textActiveCommandGuard: count( + composer.textComposer.middlewareExecutor.installedMiddleware, + 'stream-io/text-composer/active-command-guard', + ), + textCommandStringExtraction: count( + composer.textComposer.middlewareExecutor.installedMiddleware, + 'stream-io/text-composer/command-string-extraction', + ), + }; +}; + +describe('setupCommandUIMiddlewares', () => { + it('inserts each command middleware once, however often it runs on the same composer', async () => { + const { + channels: [channel], + } = await initiateClientWithChannels(); + const composer = channel.messageComposer; + + // The `messageComposer` setup function re-runs on every config change and re-subscription. + setupCommandUIMiddlewares(composer); + setupCommandUIMiddlewares(composer); + setupCommandUIMiddlewares(composer); + + expect(countMiddleware(composer)).toEqual({ + composition: 1, + draftComposition: 1, + textActiveCommandGuard: 1, + textCommandStringExtraction: 1, + }); + }); +}); diff --git a/package/src/utils/setupCommandUIMiddlewares.ts b/package/src/utils/setupCommandUIMiddlewares.ts index 0803741ac7..8bd0dce3d9 100644 --- a/package/src/utils/setupCommandUIMiddlewares.ts +++ b/package/src/utils/setupCommandUIMiddlewares.ts @@ -17,15 +17,18 @@ export const setupCommandUIMiddlewares = (messageComposer: MessageComposer) => { messageComposer.draftCompositionMiddlewareExecutor.insert({ middleware: [createDraftCommandInjectionMiddleware(messageComposer)], position: { after: 'stream-io/message-composer-middleware/draft-attachments' }, + unique: true, }); messageComposer.textComposer.middlewareExecutor.insert({ middleware: [createActiveCommandGuardMiddleware() as TextComposerMiddleware], position: { before: 'stream-io/text-composer/commands-middleware' }, + unique: true, }); messageComposer.textComposer.middlewareExecutor.insert({ middleware: [createCommandStringExtractionMiddleware() as TextComposerMiddleware], position: { after: 'stream-io/text-composer/commands-middleware' }, + unique: true, }); }; diff --git a/yarn.lock b/yarn.lock index ffb0f03380..df9f280f45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6933,7 +6933,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-web: "npm:~0.21.4" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^10.0.0-rc.28" + stream-chat: "npm:^10.0.0-rc.30" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -9569,7 +9569,7 @@ __metadata: react-native-svg: "npm:^15.15.5" react-native-teleport: "npm:^1.2.2" react-native-worklets: "npm:^0.13.0" - stream-chat: "npm:^10.0.0-rc.28" + stream-chat: "npm:^10.0.0-rc.30" stream-chat-react-native: "workspace:^" typescript: "npm:6.0.3" languageName: unknown @@ -10952,7 +10952,7 @@ __metadata: react-native-svg: "npm:15.15.5" react-native-teleport: "npm:^1.2.2" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^10.0.0-rc.28" + stream-chat: "npm:^10.0.0-rc.30" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18558,7 +18558,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-video: "npm:^6.19.3" react-native-worklets: "npm:^0.13.0" - stream-chat: "npm:^10.0.0-rc.28" + stream-chat: "npm:^10.0.0-rc.30" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -19371,7 +19371,7 @@ __metadata: react-syntax-highlighter: "npm:^16.1.0" react-test-renderer: "npm:19.3.0" rimraf: "npm:^6.0.1" - stream-chat: "npm:^10.0.0-rc.28" + stream-chat: "npm:^10.0.0-rc.30" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.7.0" uuid: "npm:^11.1.0" @@ -19459,9 +19459,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.30": + version: 10.0.0-rc.30 + resolution: "stream-chat@npm:10.0.0-rc.30" dependencies: "@stream-io/logger": "npm:^2.1.0" "@stream-io/state-store": "npm:^1.2.0" @@ -19472,7 +19472,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/0132ae268d1b228eeb8b70a08b03eff77e955f8a2215fcf7804fa4df8dd7c032adc3a356d934cbf883fd4835f823612ec9d53ccf3948c494023ae5d636341f01 + checksum: 10c0/7b37807f87081900ea407dba5fd26f8ad345ab7c7e8c4caf3b40c6bafb20a1fc56a0c7249a760d12949c700de5e633186b5c49c380f08d2c908e583d999f174f languageName: node linkType: hard