From 3606c6bee2bf9dc16dbb2692d7e8d7a3f6af1960 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 3 Sep 2026 16:10:11 -0300 Subject: [PATCH 1/2] refactor(MessageComposer): restore memoization and narrow store subscriptions ComposerInput emits React Compiler errors, so it is never auto-memoized: restore its memo wrapper and the useFocusEffect useCallback deps, which the emitter cleanup depends on. Hoist MessageComposerContainer's default children out of JSX so the file compiles again, narrow CancelEdit and useChooseMedia to useMessageActionKind, name the a11y announce delay and use optional call syntax for onClosed. --- .../MessageComposer/MessageComposer.tsx | 2 +- .../MessageComposerContainer.tsx | 4 +- .../useAutocompleteA11yAnnounce.ts | 5 +- .../MessageComposer/components/CancelEdit.tsx | 6 +- .../components/ComposerInput.tsx | 642 +++++++++--------- .../hooks/useChooseMedia.test.tsx | 10 +- .../MessageComposer/hooks/useChooseMedia.ts | 6 +- .../{ => __tests__}/useMessages.test.tsx | 0 .../hooks/{ => __tests__}/useScroll.test.tsx | 0 9 files changed, 341 insertions(+), 334 deletions(-) rename app/views/RoomView/List/hooks/{ => __tests__}/useMessages.test.tsx (100%) rename app/views/RoomView/List/hooks/{ => __tests__}/useScroll.test.tsx (100%) diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx index 6805a659507..0e881a59276 100644 --- a/app/containers/MessageComposer/MessageComposer.tsx +++ b/app/containers/MessageComposer/MessageComposer.tsx @@ -85,7 +85,7 @@ export const MessageComposer = ({ const closeEmojiKeyboardAndAction = (onClosed?: Function, params?: any) => { resetKeyboard(); - onClosed && onClosed(params); + onClosed?.(params); }; useImperativeHandle(forwardedRef, () => ({ diff --git a/app/containers/MessageComposer/MessageComposerContainer.tsx b/app/containers/MessageComposer/MessageComposerContainer.tsx index b24608a61b8..eb4645d384e 100644 --- a/app/containers/MessageComposer/MessageComposerContainer.tsx +++ b/app/containers/MessageComposer/MessageComposerContainer.tsx @@ -6,8 +6,10 @@ import { MessageComposer } from './MessageComposer'; import { EmojiKeyboardProvider } from './hooks/useEmojiKeyboard'; import { ComposerAttachments } from './components/Attachments/ComposerAttachments'; +const defaultChildren = ; + export const MessageComposerContainer = forwardRef( - ({ children = }, ref): ReactElement => { + ({ children = defaultChildren }, ref): ReactElement => { return ( diff --git a/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts index 30dadfa9d1f..ee547191239 100644 --- a/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts +++ b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts @@ -4,6 +4,8 @@ import { AccessibilityInfo } from 'react-native'; import I18n from '../../../../i18n'; import { useIsAutocompleteVisible } from '../../../../views/RoomView/stores/ComposerStore'; +const DELAY_TO_AVOID_KEYBOARD_ANNOUNCEMENT_CONFLICT = 800; + export const useAutocompleteA11yAnnounce = (): void => { const isAutocompleteVisible = useIsAutocompleteVisible(); @@ -12,10 +14,9 @@ export const useAutocompleteA11yAnnounce = (): void => { return; } - // timeout to prevent conflict with default keyboard announcement. const timeout = setTimeout(() => { AccessibilityInfo.announceForAccessibility(I18n.t('The_autocomplete_options_are_available_above_the_input_composer')); - }, 800); + }, DELAY_TO_AVOID_KEYBOARD_ANNOUNCEMENT_CONFLICT); return () => clearTimeout(timeout); }, [isAutocompleteVisible]); diff --git a/app/containers/MessageComposer/components/CancelEdit.tsx b/app/containers/MessageComposer/components/CancelEdit.tsx index f99ce062124..a065bfbe1fe 100644 --- a/app/containers/MessageComposer/components/CancelEdit.tsx +++ b/app/containers/MessageComposer/components/CancelEdit.tsx @@ -1,13 +1,13 @@ import { BaseButton } from './Buttons'; import { useEditCancel } from '../../../views/RoomView/stores/ComposerStore'; -import { useMessageAction } from '../../message/stores/MessageActionStore'; +import { useMessageActionKind } from '../../message/stores/MessageActionStore'; import { Gap } from './Gap'; export const CancelEdit = () => { const editCancel = useEditCancel(); - const action = useMessageAction(); + const actionKind = useMessageActionKind(); - if (action?.kind !== 'edit') { + if (actionKind !== 'edit') { return null; } return ( diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx index 4df191d42e6..7fa78293e9b 100644 --- a/app/containers/MessageComposer/components/ComposerInput.tsx +++ b/app/containers/MessageComposer/components/ComposerInput.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; import { TextInput, StyleSheet, type TextInputProps, InteractionManager } from 'react-native'; import { useDebouncedCallback } from 'use-debounce'; import { useDispatch } from 'react-redux'; @@ -54,352 +54,356 @@ import { isExternalKeyboardConnected } from '../../../lib/methods/helpers/extern const defaultSelection: IInputSelection = { start: 0, end: 0 }; -export const ComposerInput = forwardRef(({ inputRef }, ref) => { - const { colors, theme } = useTheme(); - const rid = useComposerRid(); - const tmid = useComposerTmid(); - const sharing = useComposerSharing(); - const setQuotesAndText = useSetQuotesAndText(); - const room = useComposerRoom(); - const action = useMessageAction(); - const focused = useFocused(); - const { setFocused, setMicOrSend, setAutocompleteParams } = useMessageComposerApi(); - const autocompleteType = useAutocompleteParams()?.type; - const textRef = useRef(''); - const firstRender = useRef(true); - const selectionRef = useRef(defaultSelection); - const dispatch = useDispatch(); - const isMasterDetail = useMasterDetail(); - const altTextSupported = useAltTextSupported(); - let placeholder = tmid ? I18n.t('Add_thread_reply') : ''; - if (room && !tmid) { - placeholder = I18n.t('Message_roomname', { roomName: (room.t === 'd' ? '@' : '#') + getRoomTitle(room) }); - if (!isTablet && placeholder.length > COMPOSER_INPUT_PLACEHOLDER_MAX_LENGTH) { - placeholder = `${placeholder.slice(0, COMPOSER_INPUT_PLACEHOLDER_MAX_LENGTH)}...`; +export const ComposerInput = memo( + forwardRef(({ inputRef }, ref) => { + const { colors, theme } = useTheme(); + const rid = useComposerRid(); + const tmid = useComposerTmid(); + const sharing = useComposerSharing(); + const setQuotesAndText = useSetQuotesAndText(); + const room = useComposerRoom(); + const action = useMessageAction(); + const focused = useFocused(); + const { setFocused, setMicOrSend, setAutocompleteParams } = useMessageComposerApi(); + const autocompleteType = useAutocompleteParams()?.type; + const textRef = useRef(''); + const firstRender = useRef(true); + const selectionRef = useRef(defaultSelection); + const dispatch = useDispatch(); + const isMasterDetail = useMasterDetail(); + const altTextSupported = useAltTextSupported(); + let placeholder = tmid ? I18n.t('Add_thread_reply') : ''; + if (room && !tmid) { + placeholder = I18n.t('Message_roomname', { roomName: (room.t === 'd' ? '@' : '#') + getRoomTitle(room) }); + if (!isTablet && placeholder.length > COMPOSER_INPUT_PLACEHOLDER_MAX_LENGTH) { + placeholder = `${placeholder.slice(0, COMPOSER_INPUT_PLACEHOLDER_MAX_LENGTH)}...`; + } } - } - const route = useRoute>(); - const usedCannedResponse = route.params?.usedCannedResponse; - const prevAction = usePrevious(action); - - // subscribe to changes on mic state to update draft after a message is sent - useMicOrSend(); - const { saveMessageDraft } = useAutoSaveDraft(textRef.current); - - // workaround to handle issues with iOS back swipe navigation - const { iOSBackSwipe } = useIOSBackSwipeHandler(); - - // Draft/Canned Responses - useEffect(() => { - const setDraftMessage = async () => { - const draftMessage = await loadDraftMessage({ rid, tmid }); - if (draftMessage) { - const parsedDraft = parseJson(draftMessage); - if (parsedDraft?.msg || parsedDraft?.quotes) { - setQuotesAndText?.(parsedDraft.msg, parsedDraft.quotes); - } else { - setInput(draftMessage); + const route = useRoute>(); + const usedCannedResponse = route.params?.usedCannedResponse; + const prevAction = usePrevious(action); + + // subscribe to changes on mic state to update draft after a message is sent + useMicOrSend(); + const { saveMessageDraft } = useAutoSaveDraft(textRef.current); + + // workaround to handle issues with iOS back swipe navigation + const { iOSBackSwipe } = useIOSBackSwipeHandler(); + + // Draft/Canned Responses + useEffect(() => { + const setDraftMessage = async () => { + const draftMessage = await loadDraftMessage({ rid, tmid }); + if (draftMessage) { + const parsedDraft = parseJson(draftMessage); + if (parsedDraft?.msg || parsedDraft?.quotes) { + setQuotesAndText?.(parsedDraft.msg, parsedDraft.quotes); + } else { + setInput(draftMessage); + } } + }; + + if (action?.kind !== 'edit' && firstRender.current) { + firstRender.current = false; + setDraftMessage(); } - }; + if (sharing) return; + if (usedCannedResponse) setInput(usedCannedResponse); + }, [action?.kind, rid, tmid, usedCannedResponse]); + + // Edit/quote + useEffect(() => { + const fetchMessageAndSetInput = async (messageId: string) => { + const message = await getMessageById(messageId); + if (message) { + setInput(message?.msg || (altTextSupported ? '' : message?.attachments?.[0]?.description || '')); + } + }; - if (action?.kind !== 'edit' && firstRender.current) { - firstRender.current = false; - setDraftMessage(); - } - if (sharing) return; - if (usedCannedResponse) setInput(usedCannedResponse); - }, [action?.kind, rid, tmid, usedCannedResponse]); - - // Edit/quote - useEffect(() => { - const fetchMessageAndSetInput = async (messageId: string) => { - const message = await getMessageById(messageId); - if (message) { - setInput(message?.msg || (altTextSupported ? '' : message?.attachments?.[0]?.description || '')); + if (sharing) return; + + if (prevAction?.kind === 'edit' && action?.kind !== 'edit') { + setInput(''); + return; + } + if (action?.kind === 'edit') { + focus(); + fetchMessageAndSetInput(action.messageId); + return; + } + if (action?.kind === 'quote' && action.messageIds.length) { + focus(); + } + }, [action]); + + useFocusEffect( + useCallback(() => { + const task = InteractionManager.runAfterInteractions(() => { + emitter.on('addMarkdown', ({ style }) => { + const { start, end } = selectionRef.current; + const text = textRef.current; + const markdown = MARKDOWN_STYLES[style]; + const newText = `${text.substr(0, start)}${markdown}${text.substr(start, end - start)}${markdown}${text.substr(end)}`; + setInput(newText, { + start: start + markdown.length, + end: start === end ? start + markdown.length : end + markdown.length + }); + }); + emitter.on('toolbarMention', () => { + if (autocompleteType) { + return; + } + const { start, end } = selectionRef.current; + const text = textRef.current; + const newText = `${text.substr(0, start)}@${text.substr(start, end - start)}${text.substr(end)}`; + setInput(newText, { start: start + 1, end: start === end ? start + 1 : end + 1 }); + setAutocompleteParams({ text: '', type: '@' }); + }); + }); + return () => { + emitter.off('addMarkdown'); + emitter.off('toolbarMention'); + task?.cancel(); + }; + }, [rid, tmid, autocompleteType]) + ); + + useImperativeHandle(ref, () => ({ + getTextAndClear: () => { + const text = textRef.current; + setInput('', undefined, true); + return text; + }, + getText: () => textRef.current, + getSelection: () => selectionRef.current, + setInput, + onAutocompleteItemSelected, + focus + })); + + const setInput: TSetInput = (text, selection, forceUpdateDraftMessage) => { + const message = text.trim(); + textRef.current = message; + + if (forceUpdateDraftMessage) { + saveMessageDraft(''); } - }; - if (sharing) return; + inputRef.current?.setNativeProps?.({ text }); - if (prevAction?.kind === 'edit' && action?.kind !== 'edit') { - setInput(''); - return; - } - if (action?.kind === 'edit') { - focus(); - fetchMessageAndSetInput(action.messageId); - return; - } - if (action?.kind === 'quote' && action.messageIds.length) { - focus(); - } - }, [action]); + if (selection) { + // setSelection won't trigger onSelectionChange, so we need it to be ran after new text is set + setTimeout(() => { + inputRef.current?.setSelection?.(selection.start, selection.end); + selectionRef.current = selection; + }, 50); + } + setMicOrSend(message.length === 0 ? 'mic' : 'send'); + }; - useFocusEffect(() => { - const task = InteractionManager.runAfterInteractions(() => { - emitter.on('addMarkdown', ({ style }) => { - const { start, end } = selectionRef.current; - const text = textRef.current; - const markdown = MARKDOWN_STYLES[style]; - const newText = `${text.substr(0, start)}${markdown}${text.substr(start, end - start)}${markdown}${text.substr(end)}`; - setInput(newText, { - start: start + markdown.length, - end: start === end ? start + markdown.length : end + markdown.length - }); - }); - emitter.on('toolbarMention', () => { - if (autocompleteType) { - return; + const focus = () => { + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); } - const { start, end } = selectionRef.current; - const text = textRef.current; - const newText = `${text.substr(0, start)}@${text.substr(start, end - start)}${text.substr(end)}`; - setInput(newText, { start: start + 1, end: start === end ? start + 1 : end + 1 }); - setAutocompleteParams({ text: '', type: '@' }); - }); - }); - return () => { - emitter.off('addMarkdown'); - emitter.off('toolbarMention'); - task?.cancel(); + }, 300); }; - }); - useImperativeHandle(ref, () => ({ - getTextAndClear: () => { - const text = textRef.current; - setInput('', undefined, true); - return text; - }, - getText: () => textRef.current, - getSelection: () => selectionRef.current, - setInput, - onAutocompleteItemSelected, - focus - })); - - const setInput: TSetInput = (text, selection, forceUpdateDraftMessage) => { - const message = text.trim(); - textRef.current = message; - - if (forceUpdateDraftMessage) { - saveMessageDraft(''); - } + const onChangeText: TextInputProps['onChangeText'] = text => { + textRef.current = text; + debouncedOnChangeText(text); + setInput(text); + }; - inputRef.current?.setNativeProps?.({ text }); + const onSelectionChange: TextInputProps['onSelectionChange'] = e => { + selectionRef.current = e.nativeEvent.selection; + }; - if (selection) { - // setSelection won't trigger onSelectionChange, so we need it to be ran after new text is set - setTimeout(() => { - inputRef.current?.setSelection?.(selection.start, selection.end); - selectionRef.current = selection; - }, 50); - } - setMicOrSend(message.length === 0 ? 'mic' : 'send'); - }; + const onFocus: TextInputProps['onFocus'] = () => { + setFocused(true); + }; - const focus = () => { - setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); + const onTouchStart: TextInputProps['onTouchStart'] = () => { + setFocused(true); + }; + + const onBlur: TextInputProps['onBlur'] = () => { + if (!iOSBackSwipe.current && !isExternalKeyboardConnected()) { + setFocused(false); + stopAutocomplete(); } - }, 300); - }; - - const onChangeText: TextInputProps['onChangeText'] = text => { - textRef.current = text; - debouncedOnChangeText(text); - setInput(text); - }; - - const onSelectionChange: TextInputProps['onSelectionChange'] = e => { - selectionRef.current = e.nativeEvent.selection; - }; - - const onFocus: TextInputProps['onFocus'] = () => { - setFocused(true); - }; - - const onTouchStart: TextInputProps['onTouchStart'] = () => { - setFocused(true); - }; - - const onBlur: TextInputProps['onBlur'] = () => { - if (!iOSBackSwipe.current && !isExternalKeyboardConnected()) { - setFocused(false); - stopAutocomplete(); - } - }; + }; - const onAutocompleteItemSelected: IAutocompleteItemProps['onPress'] = async item => { - if (item.type === 'loading') { - return null; - } + const onAutocompleteItemSelected: IAutocompleteItemProps['onPress'] = async item => { + if (item.type === 'loading') { + return null; + } - // If it's slash command preview, we need to execute the command - if (item.type === '/preview') { - try { - if (!rid) return; - const db = database.active; - const commandsCollection = db.get('slash_commands'); - const commandRecord = await commandsCollection.find(item.text); - const { appId } = commandRecord; - const triggerId = generateTriggerId(appId); - executeCommandPreview(item.text, item.params, rid, item.preview, triggerId, tmid); - } catch (e) { - log(e); + // If it's slash command preview, we need to execute the command + if (item.type === '/preview') { + try { + if (!rid) return; + const db = database.active; + const commandsCollection = db.get('slash_commands'); + const commandRecord = await commandsCollection.find(item.text); + const { appId } = commandRecord; + const triggerId = generateTriggerId(appId); + executeCommandPreview(item.text, item.params, rid, item.preview, triggerId, tmid); + } catch (e) { + log(e); + } + requestAnimationFrame(() => { + stopAutocomplete(); + setInput('', { start: 0, end: 0 }); + }); + return; } - requestAnimationFrame(() => { - stopAutocomplete(); - setInput('', { start: 0, end: 0 }); - }); - return; - } - // If it's canned response, but there's no canned responses, we open the canned responses view - if (item.type === '!' && item.id === NO_CANNED_RESPONSES) { - const params = { rid }; - if (isMasterDetail) { - Navigation.navigate('ModalStackNavigator', { screen: 'CannedResponsesListView', params }); - } else { - Navigation.navigate('CannedResponsesListView', params); + // If it's canned response, but there's no canned responses, we open the canned responses view + if (item.type === '!' && item.id === NO_CANNED_RESPONSES) { + const params = { rid }; + if (isMasterDetail) { + Navigation.navigate('ModalStackNavigator', { screen: 'CannedResponsesListView', params }); + } else { + Navigation.navigate('CannedResponsesListView', params); + } + stopAutocomplete(); + return; } - stopAutocomplete(); - return; - } - const text = textRef.current; - const { start, end } = selectionRef.current; - const cursor = Math.max(start, end); - const regexp = getMentionRegexp(); - let textBeforeMention = text.substr(0, cursor).replace(regexp, ''); - // Remove the ! after select the canned response - if (item.type === '!') { - const lastIndexOfExclamation = text.lastIndexOf('!', cursor); - textBeforeMention = text.substr(0, lastIndexOfExclamation).replace(regexp, ''); - } - let mention = ''; - switch (item.type) { - case '@': - mention = fetchIsAllOrHere(item) ? item.title : item.subtitle || item.title; - break; - case '#': - mention = item.subtitle ? item.subtitle : ''; - break; - case ':': - mention = `${typeof item.emoji === 'string' ? item.emoji : item.emoji.name}:`; - break; - case '/': - mention = item.title; - break; - case '!': - mention = item.subtitle ? item.subtitle : ''; - break; - default: - mention = ''; - } - const newText = `${textBeforeMention}${mention} ${text.slice(cursor)}`; + const text = textRef.current; + const { start, end } = selectionRef.current; + const cursor = Math.max(start, end); + const regexp = getMentionRegexp(); + let textBeforeMention = text.substr(0, cursor).replace(regexp, ''); + // Remove the ! after select the canned response + if (item.type === '!') { + const lastIndexOfExclamation = text.lastIndexOf('!', cursor); + textBeforeMention = text.substr(0, lastIndexOfExclamation).replace(regexp, ''); + } + let mention = ''; + switch (item.type) { + case '@': + mention = fetchIsAllOrHere(item) ? item.title : item.subtitle || item.title; + break; + case '#': + mention = item.subtitle ? item.subtitle : ''; + break; + case ':': + mention = `${typeof item.emoji === 'string' ? item.emoji : item.emoji.name}:`; + break; + case '/': + mention = item.title; + break; + case '!': + mention = item.subtitle ? item.subtitle : ''; + break; + default: + mention = ''; + } + const newText = `${textBeforeMention}${mention} ${text.slice(cursor)}`; - const newCursor = textBeforeMention.length + mention.length + 1; - setInput(newText, { start: newCursor, end: newCursor }); - focus(); - requestAnimationFrame(() => { - stopAutocomplete(); - }); - }; + const newCursor = textBeforeMention.length + mention.length + 1; + setInput(newText, { start: newCursor, end: newCursor }); + focus(); + requestAnimationFrame(() => { + stopAutocomplete(); + }); + }; - const stopAutocomplete = () => { - setAutocompleteParams({ text: '', type: null, params: '' }); - }; + const stopAutocomplete = () => { + setAutocompleteParams({ text: '', type: null, params: '' }); + }; - const debouncedOnChangeText = useDebouncedCallback(async (text: string) => { - const isTextEmpty = text.length === 0; - handleTyping(!isTextEmpty); - if (isTextEmpty || !focused) { - stopAutocomplete(); - return; - } - const { start, end } = selectionRef.current; - const cursor = Math.max(start, end); - const whiteSpaceOrBreakLineRegex = /[\s\n]+/; - const txt = - cursor < text.length ? text.substr(0, cursor).split(whiteSpaceOrBreakLineRegex) : text.split(whiteSpaceOrBreakLineRegex); - const lastWord = txt[txt.length - 1]; - const autocompleteText = lastWord.substring(1); - - if (!lastWord) { - stopAutocomplete(); - return; - } - if (!sharing && text.match(/^\//)) { - const commandParameter = text.match(/^\/([a-z0-9._-]+) (.+)/im); - if (commandParameter) { - const db = database.active; - const [, command, params] = commandParameter; - const commandsCollection = db.get('slash_commands'); - try { - const commandRecord = await commandsCollection.find(command); - if (commandRecord.providesPreview) { - setAutocompleteParams({ params, text: command, type: '/preview' }); + const debouncedOnChangeText = useDebouncedCallback(async (text: string) => { + const isTextEmpty = text.length === 0; + handleTyping(!isTextEmpty); + if (isTextEmpty || !focused) { + stopAutocomplete(); + return; + } + const { start, end } = selectionRef.current; + const cursor = Math.max(start, end); + const whiteSpaceOrBreakLineRegex = /[\s\n]+/; + const txt = + cursor < text.length ? text.substr(0, cursor).split(whiteSpaceOrBreakLineRegex) : text.split(whiteSpaceOrBreakLineRegex); + const lastWord = txt[txt.length - 1]; + const autocompleteText = lastWord.substring(1); + + if (!lastWord) { + stopAutocomplete(); + return; + } + if (!sharing && text.match(/^\//)) { + const commandParameter = text.match(/^\/([a-z0-9._-]+) (.+)/im); + if (commandParameter) { + const db = database.active; + const [, command, params] = commandParameter; + const commandsCollection = db.get('slash_commands'); + try { + const commandRecord = await commandsCollection.find(command); + if (commandRecord.providesPreview) { + setAutocompleteParams({ params, text: command, type: '/preview' }); + } + return; + } catch (e) { + // do nothing } - return; - } catch (e) { - // do nothing } + setAutocompleteParams({ text: autocompleteText, type: '/' }); + return; + } + if (lastWord.match(/^#/)) { + setAutocompleteParams({ text: autocompleteText, type: '#' }); + return; + } + if (lastWord.match(/^@/)) { + setAutocompleteParams({ text: autocompleteText, type: '@' }); + return; + } + if (lastWord.match(/^:/)) { + setAutocompleteParams({ text: autocompleteText, type: ':' }); + return; + } + if (lastWord.match(/^!/) && room?.t === 'l') { + setAutocompleteParams({ text: autocompleteText, type: '!' }); + return; } - setAutocompleteParams({ text: autocompleteText, type: '/' }); - return; - } - if (lastWord.match(/^#/)) { - setAutocompleteParams({ text: autocompleteText, type: '#' }); - return; - } - if (lastWord.match(/^@/)) { - setAutocompleteParams({ text: autocompleteText, type: '@' }); - return; - } - if (lastWord.match(/^:/)) { - setAutocompleteParams({ text: autocompleteText, type: ':' }); - return; - } - if (lastWord.match(/^!/) && room?.t === 'l') { - setAutocompleteParams({ text: autocompleteText, type: '!' }); - return; - } - stopAutocomplete(); - }, textInputDebounceTime); - - const handleTyping = (isTyping: boolean) => { - if (sharing || !rid) return; - dispatch(userTyping(rid, isTyping, tmid ? { tmid } : {})); - }; - - return ( - { - inputRef.current = component; - }} - blurOnSubmit={false} - onChangeText={onChangeText} - onTouchStart={onTouchStart} - onSelectionChange={onSelectionChange} - onFocus={onFocus} - onBlur={onBlur} - underlineColorAndroid='transparent' - defaultValue='' - multiline - {...(autocompleteType ? { autoComplete: 'off', autoCorrect: false, autoCapitalize: 'none' } : {})} - keyboardAppearance={theme === 'light' ? 'light' : 'dark'} - // eslint-disable-next-line no-nested-ternary - testID={`message-composer-input${tmid ? '-thread' : sharing ? '-share' : ''}`} - /> - ); -}); + stopAutocomplete(); + }, textInputDebounceTime); + + const handleTyping = (isTyping: boolean) => { + if (sharing || !rid) return; + dispatch(userTyping(rid, isTyping, tmid ? { tmid } : {})); + }; + + return ( + { + inputRef.current = component; + }} + blurOnSubmit={false} + onChangeText={onChangeText} + onTouchStart={onTouchStart} + onSelectionChange={onSelectionChange} + onFocus={onFocus} + onBlur={onBlur} + underlineColorAndroid='transparent' + defaultValue='' + multiline + {...(autocompleteType ? { autoComplete: 'off', autoCorrect: false, autoCapitalize: 'none' } : {})} + keyboardAppearance={theme === 'light' ? 'light' : 'dark'} + // eslint-disable-next-line no-nested-ternary + testID={`message-composer-input${tmid ? '-thread' : sharing ? '-share' : ''}`} + /> + ); + }) +); const styles = StyleSheet.create({ textInput: { diff --git a/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx b/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx index a827fc792b0..bffc8304d2a 100644 --- a/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx +++ b/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx @@ -20,7 +20,7 @@ jest.mock('../../../views/RoomView/stores/ComposerStore', () => ({ })); jest.mock('../../message/stores/MessageActionStore', () => ({ - useMessageAction: jest.fn(), + useMessageActionKind: jest.fn(), useQuotedMessageIds: jest.fn(() => []) })); @@ -53,7 +53,7 @@ const mockUseAppSelector = require('../../../lib/hooks/useAppSelector').useAppSe const mockUseMessageComposerApi = require('../context').useMessageComposerApi as jest.Mock; const mockUseSetQuotesAndText = require('../../../views/RoomView/stores/ComposerStore').useSetQuotesAndText as jest.Mock; const mockUseGetText = require('../../../views/RoomView/stores/ComposerStore').useGetText as jest.Mock; -const mockUseMessageAction = require('../../message/stores/MessageActionStore').useMessageAction as jest.Mock; +const mockUseMessageActionKind = require('../../message/stores/MessageActionStore').useMessageActionKind as jest.Mock; const mockUseQuotedMessageIds = require('../../message/stores/MessageActionStore').useQuotedMessageIds as jest.Mock; const mockUseAltTextSupported = require('../../../lib/hooks/useAltTextSupported').useAltTextSupported as jest.Mock; const mockGetSubscriptionByRoomId = require('../../../lib/database/services/Subscription').getSubscriptionByRoomId as jest.Mock; @@ -77,7 +77,7 @@ describe('useChooseMedia', () => { mockUseMessageComposerApi.mockReturnValue({ addAttachments }); mockUseSetQuotesAndText.mockReturnValue(jest.fn()); mockUseGetText.mockReturnValue(jest.fn(() => 'draft')); - mockUseMessageAction.mockReturnValue(null); + mockUseMessageActionKind.mockReturnValue(null); mockGetSubscriptionByRoomId.mockResolvedValue({ rid: 'room-id', t: 'c' }); mockGetThreadById.mockResolvedValue({ id: 'thread-id' }); }); @@ -136,7 +136,7 @@ describe('useChooseMedia', () => { it('forwards quoted message ids to ShareView as selectedMessages', async () => { mockUseAltTextSupported.mockReturnValue(false); - mockUseMessageAction.mockReturnValue({ kind: 'quote', messageIds: ['msg-1', 'msg-2'] }); + mockUseMessageActionKind.mockReturnValue('quote'); mockUseQuotedMessageIds.mockReturnValue(['msg-1', 'msg-2']); mockGetDocumentAsync.mockResolvedValue({ canceled: false, @@ -155,7 +155,7 @@ describe('useChooseMedia', () => { it('does not quote the message when the action is edit', async () => { mockUseAltTextSupported.mockReturnValue(false); - mockUseMessageAction.mockReturnValue({ kind: 'edit', messageId: 'msg-1' }); + mockUseMessageActionKind.mockReturnValue('edit'); mockUseQuotedMessageIds.mockReturnValue([]); mockGetDocumentAsync.mockResolvedValue({ canceled: false, diff --git a/app/containers/MessageComposer/hooks/useChooseMedia.ts b/app/containers/MessageComposer/hooks/useChooseMedia.ts index 0f723e86421..d953ad83916 100644 --- a/app/containers/MessageComposer/hooks/useChooseMedia.ts +++ b/app/containers/MessageComposer/hooks/useChooseMedia.ts @@ -10,7 +10,7 @@ import { getThreadById } from '../../../lib/database/services/Thread'; import Navigation from '../../../lib/navigation/appNavigation'; import { useAppSelector } from '../../../lib/hooks/useAppSelector'; import { useGetText, useSetQuotesAndText } from '../../../views/RoomView/stores/ComposerStore'; -import { useMessageAction, useQuotedMessageIds } from '../../message/stores/MessageActionStore'; +import { useMessageActionKind, useQuotedMessageIds } from '../../message/stores/MessageActionStore'; import { type IShareAttachment } from '../../../definitions'; import ImagePicker, { type ImageOrVideo } from '../../../lib/methods/helpers/ImagePicker/ImagePicker'; import { useMessageComposerApi } from '../context'; @@ -32,7 +32,7 @@ export const useChooseMedia = ({ const { addAttachments } = useMessageComposerApi(); const setQuotesAndText = useSetQuotesAndText(); const getText = useGetText(); - const action = useMessageAction(); + const actionKind = useMessageActionKind(); const quotedMessageIds = useQuotedMessageIds(); const altTextSupported = useAltTextSupported(); const allowList = FileUpload_MediaTypeWhiteList as string; @@ -117,7 +117,7 @@ export const useChooseMedia = ({ room, thread: thread || tmid, attachments, - action: action?.kind ?? null, + action: actionKind, finishShareView, startShareView }); diff --git a/app/views/RoomView/List/hooks/useMessages.test.tsx b/app/views/RoomView/List/hooks/__tests__/useMessages.test.tsx similarity index 100% rename from app/views/RoomView/List/hooks/useMessages.test.tsx rename to app/views/RoomView/List/hooks/__tests__/useMessages.test.tsx diff --git a/app/views/RoomView/List/hooks/useScroll.test.tsx b/app/views/RoomView/List/hooks/__tests__/useScroll.test.tsx similarity index 100% rename from app/views/RoomView/List/hooks/useScroll.test.tsx rename to app/views/RoomView/List/hooks/__tests__/useScroll.test.tsx From 2b70463b4b84b126101d811a36620bd65d850cd5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 3 Sep 2026 16:31:03 -0300 Subject: [PATCH 2/2] chore: keep List hook tests in place Two test file moves were committed here by mistake; they belong with the hooks changes that fix their import paths. --- .../RoomView/List/hooks/{__tests__ => }/useMessages.test.tsx | 0 app/views/RoomView/List/hooks/{__tests__ => }/useScroll.test.tsx | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename app/views/RoomView/List/hooks/{__tests__ => }/useMessages.test.tsx (100%) rename app/views/RoomView/List/hooks/{__tests__ => }/useScroll.test.tsx (100%) diff --git a/app/views/RoomView/List/hooks/__tests__/useMessages.test.tsx b/app/views/RoomView/List/hooks/useMessages.test.tsx similarity index 100% rename from app/views/RoomView/List/hooks/__tests__/useMessages.test.tsx rename to app/views/RoomView/List/hooks/useMessages.test.tsx diff --git a/app/views/RoomView/List/hooks/__tests__/useScroll.test.tsx b/app/views/RoomView/List/hooks/useScroll.test.tsx similarity index 100% rename from app/views/RoomView/List/hooks/__tests__/useScroll.test.tsx rename to app/views/RoomView/List/hooks/useScroll.test.tsx