diff --git a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx index cf0f8dceb..edca25470 100644 --- a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx +++ b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx @@ -170,15 +170,20 @@ const ChannelListSwitcher = ({ // interval), so render both here, in a section above the list. `intervalViews` emits regardless of // which interval is active, and the selector below only wakes when logicalHead/logicalTail change // (not the `head` field), so this stays reactive even while the list shows a different window. +// With no page loaded (e.g. every channel was hidden, then shown again) a logical interval is itself +// the active one, so `ChannelList` already shows its channels: those are skipped here. const sideloadedSelector = (state: PaginatorIntervalViews) => ({ head: state.logicalHead, tail: state.logicalTail, }); +const listedItemsSelector = (state: { items?: Channel[] }) => ({ items: state.items }); const SideloadedChannels = ({ paginator }: { paginator: ChannelPaginator }) => { const { head, tail } = useStateStore(paginator.intervalViews, sideloadedSelector); + const { items } = useStateStore(paginator.state, listedItemsSelector); // Head items sort newer than the loaded window, tail items older — concatenated they are all the // out-of-order (sideloaded) channels, newest first. - const channels = [...head, ...tail]; + const listed = new Set(items?.map((channel) => channel.cid)); + const channels = [...head, ...tail].filter((channel) => !listed.has(channel.cid)); if (!channels.length) return null; return (
diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index acc7085d2..5fabcf6ad 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -1,5 +1,5 @@ import type { ComponentType } from 'react'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import type { Channel, ChannelPaginator, ChannelPaginatorState } from 'stream-chat'; import { ChannelListItem } from '../ChannelListItem'; @@ -32,16 +32,20 @@ export type ChannelListProps = { loadMoreThresholdPx?: number; }; -const channelPaginatorStateSelector = (state: ChannelPaginatorState) => ({ - // `items === undefined` means "never queried": the initial state, and where a paginator returns - // when its data is discarded (e.g. `client.disconnectUser` resets every registered list). An - // empty array is a loaded empty page and must not trigger a query. A failed first page leaves - // `items` undefined too, but holds the error until a retry or a reset; with `throwErrors` set, - // that error is all that keeps the list from querying again and again. - awaitsFirstPage: state.items === undefined && !state.isLoading && !state.lastQueryError, - firstPageLoadError: getFirstPageLoadError(state), - lastQueryError: state.lastQueryError, -}); +const createChannelPaginatorStateSelector = + (paginator: ChannelPaginator) => (state: ChannelPaginatorState) => ({ + // No loaded page: the initial state, and where a paginator returns when its data is discarded + // (e.g. `client.disconnectUser` resets every registered list). Not `items === undefined`: a + // live event can route a channel into a list that was never queried, which defines `items` + // without loading a page. A loaded empty page (also one set with `setItems`, e.g. a catch-all + // list seeded so it never queries) must not trigger a query. A failed first page loads none + // either, but holds the error until a retry or a reset; with `throwErrors` set, that error is + // all that keeps the list from querying again and again. + awaitsFirstPage: + !paginator.hasLoadedPage && !state.isLoading && !state.lastQueryError, + firstPageLoadError: getFirstPageLoadError(state), + lastQueryError: state.lastQueryError, + }); /** * Channel list driven by a single `ChannelPaginator`: it renders the paginator's state and drives @@ -55,6 +59,10 @@ export const ChannelList = ({ }: ChannelListProps) => { const { channelManager } = useChatContext(); const { t } = useTranslationContext(); + const channelPaginatorStateSelector = useMemo( + () => createChannelPaginatorStateSelector(paginator), + [paginator], + ); const { awaitsFirstPage, firstPageLoadError, lastQueryError } = useStateStore( paginator.state, channelPaginatorStateSelector, @@ -78,12 +86,12 @@ export const ChannelList = ({ // Ref-counted, so `Chat` and other lists can register the same subscriptions. useEffect(() => channelManager.registerSubscriptions(), [channelManager]); - // Loads the first page, and again whenever the list returns to "never queried": the paginator + // Loads the first page, and again whenever the list returns to "no page loaded": the paginator // outlives this component, so querying only on mount would leave a reset list permanently empty. - // An initialized paginator has queried already; its pages arrive through that query or its - // recovery, and a query from here would load the next page. + // A paginator with a loaded page has queried already (or was seeded); its pages arrive through + // that query or its recovery, and a query from here would load the next page. useEffect(() => { - if (!awaitsFirstPage || paginator.isInitialized) return; + if (!awaitsFirstPage) return; paginator.toTailDebounced(); }, [awaitsFirstPage, paginator]); diff --git a/src/components/ChannelList/__tests__/ChannelList.test.tsx b/src/components/ChannelList/__tests__/ChannelList.test.tsx index 87a04d0c7..e1978c587 100644 --- a/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -82,6 +82,45 @@ describe('ChannelList', () => { expect(queryChannels).not.toHaveBeenCalled(); }); + // A live event can route a channel into a list that was never queried (e.g. a message makes a + // channel unread before the "Unread" list was ever shown): `items` is then defined, but no page + // was loaded, so the first query still has to run. + it('queries the first page of a list that a live event reached first', async () => { + const { client, queryChannels } = await setupClient(); + const paginator = new ChannelPaginator({ client, id: 'channels:a' }); + paginator.ingestItem(client.channel('messaging', 'ingested')); + expect(paginator.items).toHaveLength(1); + + renderList(client, paginator); + + await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(1)); + }); + + it('queries the first page of a list that a live event reached and left again', async () => { + const { client, queryChannels } = await setupClient(); + const paginator = new ChannelPaginator({ client, id: 'channels:a' }); + const channel = client.channel('messaging', 'ingested'); + paginator.ingestItem(channel); + paginator.removeItem({ item: channel }); + expect(paginator.items).toEqual([]); + + renderList(client, paginator); + + await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(1)); + }); + + it('does not query a seeded list that a live event reached', async () => { + const { client, queryChannels } = await setupClient(); + const paginator = new ChannelPaginator({ client, id: 'channels:a' }); + paginator.setItems({ isLastPage: true, valueOrFactory: [] }); + paginator.ingestItem(client.channel('messaging', 'ingested')); + + renderList(client, paginator); + + await waitFor(() => expect(paginator.items).toHaveLength(1)); + expect(queryChannels).not.toHaveBeenCalled(); + }); + it('re-queries when the list data is discarded while it stays mounted', async () => { const { client, queryChannels } = await setupClient(); const paginator = new ChannelPaginator({ client, id: 'channels:a' }); diff --git a/src/components/Dialog/service/DialogAnchor.tsx b/src/components/Dialog/service/DialogAnchor.tsx index d3e59e679..6e5528844 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. */} +
= { 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 000000000..6485be787 --- /dev/null +++ b/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollWithComponents.test.tsx @@ -0,0 +1,73 @@ +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', + ]); + // React's argument count for this warning varies by version, so only the message is matched. + expect( + consoleError.mock.calls.some( + ([message]) => typeof message === 'string' && message.includes('same key'), + ), + ).toBe(false); + 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']); + }); +}); diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index e92d5cfdd..7a8388fb4 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 cb4b0bfa6..c2e9b0b3b 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -375,6 +375,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/components/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx index 9908d9abe..8286eddf5 100644 --- a/src/components/MessageActions/MessageActions.defaults.tsx +++ b/src/components/MessageActions/MessageActions.defaults.tsx @@ -28,6 +28,7 @@ import { import { MessageActions, type MessageActionSetItem } from './MessageActions'; import { QuickMessageActionsButton } from './QuickMessageActionButton'; import clsx from 'clsx'; +import type { LocalMessage } from 'stream-chat'; import { DeleteMessageAlert } from './DeleteMessageAlert'; import { downloadAttachment, isDownloadableAttachment } from './downloadUtils'; @@ -51,6 +52,57 @@ const getNotificationError = (error: unknown): Error | undefined => { 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. Only an anchor outside any channel or thread + * (a custom layout) falls back to the first composer in the page: inside one, a composer showing no + * textarea (while recording audio, say) leaves focus where it is rather than moving it elsewhere. + */ +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) { + return Array.from( + container.querySelectorAll(COMPOSER_TEXTAREA_SELECTOR), + ).find((candidate) => candidate.closest('.str-chat__thread') === thread); + } + 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 +187,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 +349,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 1dcb3e2ea..4e423a4c2 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.tsx +++ b/src/components/MessageActions/__tests__/MessageActions.test.tsx @@ -501,6 +501,92 @@ 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( + + + + + + + +