diff --git a/.changeset/fix-emoji-autocomplete-focus.md b/.changeset/fix-emoji-autocomplete-focus.md new file mode 100644 index 0000000000..5b90218b85 --- /dev/null +++ b/.changeset/fix-emoji-autocomplete-focus.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix a crash when emoji autocomplete results reappear while typing. diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx index 3641ec7ee1..23eaa88213 100644 --- a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx +++ b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx @@ -44,6 +44,7 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto active={isActive} focusTrapOptions={{ initialFocus: false, + fallbackFocus: () => itemsRef.current!, onPostDeactivate: handleDeactivate, returnFocusOnDeactivate: false, clickOutsideDeactivates: true, @@ -61,7 +62,7 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto {headerContent} -
+
{children}
diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.test.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.test.tsx new file mode 100644 index 0000000000..6757846496 --- /dev/null +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { IEmoji } from '$plugins/emoji'; +import type { + EditorAutocompleteQuery, + ProseMirrorEditorController, +} from '../prosemirrorController'; +import { EmoticonAutocomplete } from './EmoticonAutocomplete'; + +let searchResult: { items: IEmoji[] } | undefined; +const mocks = vi.hoisted(() => ({ + reset: vi.fn<() => void>(), + search: vi.fn<(query: string) => void>(), +})); + +vi.mock('$hooks/useMatrixClient', () => ({ useMatrixClient: () => ({}) })); +vi.mock('$hooks/useMediaAuthentication', () => ({ useMediaAuthentication: () => false })); +vi.mock('$hooks/useImagePacks', () => ({ useRelevantImagePacks: () => [] })); +vi.mock('$hooks/useRecentEmoji', () => ({ useRecentEmoji: () => [] })); +vi.mock('$hooks/useAsyncSearch', () => ({ + useAsyncSearch: () => [searchResult, mocks.search, mocks.reset], +})); +vi.mock('$hooks/useKeyDown', () => ({ useKeyDown: () => undefined })); +vi.mock('$state/hooks/settings', () => ({ useSetting: () => [2] })); +vi.mock('$utils/matrix', () => ({ mxcUrlToHttp: () => undefined })); + +const query: EditorAutocompleteQuery = { + from: 1, + prefix: ':', + text: 'zxy', + to: 4, +}; + +const emoji = { shortcode: 'zxy-face', unicode: 'Z' } as IEmoji; + +describe('EmoticonAutocomplete', () => { + afterEach(() => { + searchResult = undefined; + }); + + it('keeps the menu mounted while results change from none to matches', () => { + searchResult = { items: [] }; + const requestClose = vi.fn<() => void>(); + const { rerender } = render( + + ); + const menu = document.querySelector('[data-autocomplete-menu]')!; + + expect(screen.getByText('No emojis found')).toBeInTheDocument(); + + searchResult = { items: [emoji] }; + rerender( + + ); + + expect(menu).toBeInTheDocument(); + expect(screen.getByText(':zxy-face:')).toBeInTheDocument(); + }); +}); diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index bab4457d69..7e818f8346 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -105,57 +105,61 @@ export function EmoticonAutocomplete({ }); }); - return autoCompleteEmoticon.length === 0 ? null : ( + return ( {title ?? 'Emojis'}} requestClose={requestClose} > - {autoCompleteEmoticon.map((emoticon) => { - const isCustomEmoji = 'url' in emoticon; - const key = isCustomEmoji ? emoticon.url : emoticon.unicode; - const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication); + {autoCompleteEmoticon.length === 0 ? ( + No emojis found + ) : ( + autoCompleteEmoticon.map((emoticon) => { + const isCustomEmoji = 'url' in emoticon; + const key = isCustomEmoji ? emoticon.url : emoticon.unicode; + const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication); - return ( - ) => - onTabPress(evt, () => handleAutocomplete(key, emoticon.shortcode)) - } - onMouseDown={(evt: ReactMouseEvent) => evt.preventDefault()} - onClick={() => handleAutocomplete(key, emoticon.shortcode)} - before={ - isCustomEmoji && customEmojiUrl ? ( - - ) : ( - - {key} - - ) - } - > - - :{emoticon.shortcode}: - - - ); - })} + return ( + ) => + onTabPress(evt, () => handleAutocomplete(key, emoticon.shortcode)) + } + onMouseDown={(evt: ReactMouseEvent) => evt.preventDefault()} + onClick={() => handleAutocomplete(key, emoticon.shortcode)} + before={ + isCustomEmoji && customEmojiUrl ? ( + + ) : ( + + {key} + + ) + } + > + + :{emoticon.shortcode}: + + + ); + }) + )} ); }