From cd365d170c2b2d59f6cc0fec81e65054be94bd7e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 16:01:31 +0100 Subject: [PATCH 01/12] fix(Message): do not leave a failed retry unhandled A retry that fails again marks the message failed, which is how it reports the failure, but the rejection reached the Resend action and the bounce prompt unhandled. --- src/components/Message/Message.tsx | 5 ++++- .../Message/__tests__/Message.test.tsx | 18 ++++++++++++++++++ src/context/MessageBounceContext.tsx | 3 ++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index e92d5cfdd1..7a8388fb4f 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -147,7 +147,10 @@ export const Message = (props: MessageProps) => { const handleOpenThread = useOpenThreadHandler(message, propOpenThread); const retryHandler = useRetryHandler(); const handleRetry = useCallback( - (retriedMessage: LocalMessage) => retryHandler({ localMessage: retriedMessage }), + (retriedMessage: LocalMessage) => + // A retry that fails again marks the message failed, which is how it reports the failure; the + // rejection itself would be unhandled at every click site. + retryHandler({ localMessage: retriedMessage }).catch(() => undefined), [retryHandler], ); diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index c9f3f0e472..b34a233533 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -360,6 +360,24 @@ describe(' component', () => { }); }); + it('does not reject when a retry fails again', async () => { + const message = generateMessage(); + let context: MessageContextValue; + + await renderComponent({ + contextCallback: (ctx) => { + context = ctx; + }, + message, + }); + + vi.spyOn(lastChannel.messageOperations, 'retry').mockRejectedValue( + new Error('still failing'), + ); + + await expect(context.handleRetry(message)).resolves.toBeUndefined(); + }); + it('should trigger channel mentions handler when there is one set and user clicks on a mention', async () => { const message = generateMessage({ mentioned_users: [bob] }); const onMentionsClick = vi.fn(() => {}); diff --git a/src/context/MessageBounceContext.tsx b/src/context/MessageBounceContext.tsx index de70ce05af..238add5eba 100644 --- a/src/context/MessageBounceContext.tsx +++ b/src/context/MessageBounceContext.tsx @@ -58,7 +58,8 @@ export function MessageBounceProvider({ children }: PropsWithChildrenOnly) { ); const handleRetry: ReactEventHandler = useCallback(() => { - void doHandleRetry({ localMessage: message }); + // A failed retry marks the message failed again; that is its report, not the rejection. + doHandleRetry({ localMessage: message }).catch(() => undefined); }, [doHandleRetry, message]); const value = useMemo( From bdc59ce2c7c339262643470a6a46d3edbe9e8399 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 16:01:31 +0100 Subject: [PATCH 02/12] fix(ChannelList): key rows by the paginator item id Rows were keyed by item.id. A channel's id is unique only within its type, so channels such as team:general and livestream:general shared a key and the list rendered ghost rows on reorder. Rows are now keyed by paginator.getItemId (the cid for channels), falling back to id. --- .../InfiniteScrollWithComponents.tsx | 7 +- .../InfiniteScrollWithComponents.test.tsx | 72 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollWithComponents.test.tsx diff --git a/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx index 8f6b53b27a..1f50c4aa96 100644 --- a/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx +++ b/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx @@ -12,6 +12,11 @@ type PaginatorLike = { hasMoreHead: boolean; hasMoreTail: boolean; state: StateStore>; + /** + * The item's identity in the paginator, used as the React key. Needed where `id` is not unique: a + * channel's `id` is unique only within its type, so a channel paginator keys by `cid`. + */ + getItemId?: (item: TItem) => string; }; export type InfiniteScrollWithComponentsProps = @@ -71,7 +76,7 @@ export const InfiniteScrollWithComponents = forwardRef( {items.map((item) => ( - + ))} diff --git a/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollWithComponents.test.tsx b/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollWithComponents.test.tsx new file mode 100644 index 0000000000..4bd37ed165 --- /dev/null +++ b/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollWithComponents.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { StateStore } from '@stream-io/state-store'; +import type { PaginatorState } from 'stream-chat'; + +import { InfiniteScrollWithComponents } from '../InfiniteScrollWithComponents'; + +type Item = { cid: string; id: string; name: string }; + +const Nothing = () => null; + +const renderList = ( + state: StateStore>, + getItemId?: (item: Item) => string, +) => + render( + + EmptyListIndicator={Nothing} + EndReachedIndicator={Nothing} + FirstPageLoadingIndicator={Nothing} + ListItem={({ item }) =>
{item.name}
} + LoadingNextPageIndicator={Nothing} + paginator={{ getItemId, hasMoreHead: false, hasMoreTail: false, state }} + />, + ); + +describe('InfiniteScrollWithComponents', () => { + // Channel ids are unique only per type (`team:general` and `livestream:general`), so a channel + // list keyed by `id` shared keys between rows and React rendered ghost rows on reorder. + it('keys rows by the paginator item id, so rows sharing an `id` render once each', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const teamGeneral = { cid: 'team:general', id: 'general', name: 'team' }; + const liveGeneral = { cid: 'livestream:general', id: 'general', name: 'livestream' }; + const other = { cid: 'messaging:other', id: 'other', name: 'other' }; + const state = new StateStore>({ + hasMoreHead: false, + hasMoreTail: false, + isLoading: false, + items: [teamGeneral, liveGeneral, other], + } as PaginatorState); + + renderList(state, (item) => item.cid); + act(() => { + state.partialNext({ items: [other, liveGeneral, teamGeneral] }); + }); + + expect(screen.getAllByTestId('row').map((row) => row.textContent)).toEqual([ + 'other', + 'livestream', + 'team', + ]); + expect(consoleError).not.toHaveBeenCalledWith( + expect.stringContaining('same key'), + expect.anything(), + expect.anything(), + ); + consoleError.mockRestore(); + }); + + it('falls back to `id` when the paginator has no item id', () => { + const state = new StateStore>({ + hasMoreHead: false, + hasMoreTail: false, + isLoading: false, + items: [{ cid: 'a:1', id: '1', name: 'one' }], + } as PaginatorState); + + renderList(state); + + expect(screen.getAllByTestId('row').map((row) => row.textContent)).toEqual(['one']); + }); +}); From 423b8b9d5f9c5b28a0491e816b707b1325b87126 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 16:01:31 +0100 Subject: [PATCH 03/12] fix(MessageActions): focus the composer after Edit and Quote The actions menu traps focus and, when it closes, returns focus to its toggle, which a hover-only toggle no longer renders, so focus fell to the body and typing after Edit was lost. A closing dialog now releases the trap, and Edit and Quote focus the composer next to the message (the thread's for a reply) on the next frame, with the caret at the end for Edit. --- .../Dialog/service/DialogAnchor.tsx | 4 +- .../MessageActions.defaults.tsx | 66 +++++++++++++++---- .../__tests__/MessageActions.test.tsx | 45 ++++++++++++- 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/src/components/Dialog/service/DialogAnchor.tsx b/src/components/Dialog/service/DialogAnchor.tsx index d3e59e679c..6e5528844b 100644 --- a/src/components/Dialog/service/DialogAnchor.tsx +++ b/src/components/Dialog/service/DialogAnchor.tsx @@ -231,7 +231,9 @@ export const DialogAnchor = ({ return ( - + {/* A closing dialog releases the trap, so an action that moves focus elsewhere as it closes the + dialog (e.g. "Edit Message" focusing the composer) keeps it there. */} +
{ return undefined; }; +const COMPOSER_TEXTAREA_SELECTOR = '.str-chat__textarea__textarea'; + +/** + * The composer next to the message list the menu was opened from: the thread's for a message in a + * thread, the channel's otherwise. Several composers can be on screen (a thread panel, channels side + * by side), so it is looked up from the menu's anchor, falling back to the first one in the page. + */ +const findComposerTextarea = ( + anchor: HTMLElement | null | undefined, + message: LocalMessage, +): HTMLTextAreaElement | undefined => { + const thread = anchor?.closest('.str-chat__thread') ?? null; + const container = thread ?? anchor?.closest('.str-chat__channel'); + if (container) { + const textarea = Array.from( + container.querySelectorAll(COMPOSER_TEXTAREA_SELECTOR), + ).find((candidate) => candidate.closest('.str-chat__thread') === thread); + if (textarea) return textarea; + } + const fallback = document + .querySelectorAll( + message.parent_id + ? `.str-chat__thread ${COMPOSER_TEXTAREA_SELECTOR}` + : COMPOSER_TEXTAREA_SELECTOR, + ) + .item(0); + return fallback instanceof HTMLTextAreaElement ? fallback : undefined; +}; + +/** + * Focuses the message's composer (see {@link findComposerTextarea}) on the next frame: by then the + * menu, which traps focus while open, has started closing and no longer pulls focus back, and the + * composed text has rendered, so the caret can go after it. + */ +const focusComposerTextarea = ( + anchor: HTMLElement | null | undefined, + message: LocalMessage, + { caretAtEnd = false }: { caretAtEnd?: boolean } = {}, +) => { + // Looked up now, while the anchor is still in the document. + const textarea = findComposerTextarea(anchor, message); + if (!textarea) return; + + window.requestAnimationFrame(() => { + textarea.focus(); + if (caretAtEnd) + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + }); +}; + const DefaultMessageActionComponents = { dropdown: { React() { @@ -135,22 +186,14 @@ const DefaultMessageActionComponents = { }, Quote() { const { IconQuote } = useComponentContextIcons(); - const { closeMenu } = useContextMenuContext(); + const { anchorReferenceElement, closeMenu } = useContextMenuContext(); const { message } = useMessageContext(); const { t } = useTranslationContext(); const messageComposer = useMessageComposerController(); const handleQuote = () => { messageComposer.setQuotedMessage(message); - - const elements = message.parent_id - ? document.querySelectorAll('.str-chat__thread .str-chat__textarea__textarea') - : document.getElementsByClassName('str-chat__textarea__textarea'); - const textarea = elements.item(0); - - if (textarea instanceof HTMLTextAreaElement) { - textarea.focus(); - } + focusComposerTextarea(anchorReferenceElement, message); }; return ( @@ -305,7 +348,7 @@ const DefaultMessageActionComponents = { const messageComposer = useMessageComposerController(); const { message } = useMessageContext(); const { t } = useTranslationContext(); - const { closeMenu } = useContextMenuContext(); + const { anchorReferenceElement, closeMenu } = useContextMenuContext(); return ( { savePreEditSnapshot(messageComposer); messageComposer.initState({ composition: message }); + focusComposerTextarea(anchorReferenceElement, message, { caretAtEnd: true }); closeMenu(); }} > diff --git a/src/components/MessageActions/__tests__/MessageActions.test.tsx b/src/components/MessageActions/__tests__/MessageActions.test.tsx index c6a00bf37d..e11ec044d4 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.tsx +++ b/src/components/MessageActions/__tests__/MessageActions.test.tsx @@ -473,6 +473,48 @@ describe('', () => { expect(setQuotedMessageSpy).toHaveBeenCalledWith(message); }); + + // Closing the menu returns focus to its toggle, which a hover-only toggle no longer renders, so + // the composer was left unfocused and the first keystrokes were lost. + it('focuses the composer when Edit Message is clicked', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [{ channel: { own_capabilities: ['update-own-message'] } }], + }); + const message = generateMessage({ text: 'to edit', user: client.user }); + + await act(async () => { + await render( + + + + + + + +