diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx index 178ca3b58b1..20caa120cfc 100644 --- a/app/containers/MessageComposer/components/ComposerInput.tsx +++ b/app/containers/MessageComposer/components/ComposerInput.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle } from 'react'; +import React, { forwardRef, memo, useCallback, useContext, useEffect, useImperativeHandle } from 'react'; import { TextInput, StyleSheet, type TextInputProps, InteractionManager } from 'react-native'; import { useDebouncedCallback } from 'use-debounce'; import { useDispatch } from 'react-redux'; @@ -13,7 +13,7 @@ import { type IInputSelection, type TSetInput } from '../interfaces'; -import { useAutocompleteParams, useFocused, useMessageComposerApi, useMicOrSend } from '../context'; +import { useAutocompleteParams, useFocused, useMessageComposerApi, useMicOrSend, MessageInnerContext } from '../context'; import { fetchIsAllOrHere, getMentionRegexp } from '../helpers'; import { useAutoSaveDraft } from '../hooks'; import sharedStyles from '../../../views/Styles'; @@ -42,6 +42,8 @@ import { usePrevious } from '../../../lib/hooks/usePrevious'; import { type ChatsStackParamList } from '../../../stacks/types'; import { loadDraftMessage } from '../../../lib/methods/draftMessage'; import useIOSBackSwipeHandler from '../hooks/useIOSBackSwipeHandler'; +import { useUserPreferences } from '../../../lib/methods/userPreferences'; +import { ENTER_KEY_BEHAVIOR_PREFERENCES_KEY } from '../../../lib/constants/keys'; const defaultSelection: IInputSelection = { start: 0, end: 0 }; @@ -55,8 +57,13 @@ export const ComposerInput = memo( const textRef = React.useRef(''); const firstRender = React.useRef(true); const selectionRef = React.useRef(defaultSelection); + const shouldInterceptEnterRef = React.useRef(false); + const previousTextRef = React.useRef(''); + const isUpdatingNativePropsRef = React.useRef(false); const dispatch = useDispatch(); const isMasterDetail = useAppSelector(state => state.app.isMasterDetail); + const [enterKeyBehavior] = useUserPreferences<'SEND' | 'NEW_LINE'>(ENTER_KEY_BEHAVIOR_PREFERENCES_KEY, 'NEW_LINE'); + const { sendMessage } = useContext(MessageInnerContext); let placeholder = tmid ? I18n.t('Add_thread_reply') : ''; if (room && !tmid) { placeholder = I18n.t('Message_roomname', { roomName: (room.t === 'd' ? '@' : '#') + getRoomTitle(room) }); @@ -170,12 +177,20 @@ export const ComposerInput = memo( const setInput: TSetInput = (text, selection, forceUpdateDraftMessage) => { const message = text.trim(); textRef.current = message; + previousTextRef.current = text; // Update previous text ref for iOS newline detection if (forceUpdateDraftMessage) { saveMessageDraft(''); } + // Set flag to prevent onChangeText from firing when we update native props + isUpdatingNativePropsRef.current = true; inputRef.current?.setNativeProps?.({ text }); + // Reset flag after a microtask to allow native update to complete + // This prevents onChangeText from being triggered by setNativeProps + setTimeout(() => { + isUpdatingNativePropsRef.current = false; + }, 0); if (selection) { // setSelection won't trigger onSelectionChange, so we need it to be ran after new text is set @@ -195,12 +210,60 @@ export const ComposerInput = memo( }, 300); }; - const onChangeText: TextInputProps['onChangeText'] = text => { - textRef.current = text; - debouncedOnChangeText(text); - setInput(text); + const sendViaEnterKey = (textWithoutNewline: string) => { + // Mirror the send-button pipeline: first sync the TextInput value/refs, + // then trigger the shared send handler so it can clear the field via getTextAndClear. + setInput(textWithoutNewline); + requestAnimationFrame(() => { + sendMessage(); + }); }; + const onChangeText: TextInputProps['onChangeText'] = text => { + // Skip if we're updating native props to prevent recursive calls + if (isUpdatingNativePropsRef.current) { + isUpdatingNativePropsRef.current = false; + return; + } + + // Handle Enter key behavior for tablets - similar to Rocket.Chat desktop + // When sendOnEnter is enabled, Enter sends message instead of creating newline + if (isTablet && enterKeyBehavior === 'SEND' && !sharing && rid) { + const previousText = previousTextRef.current; + const hasNewline = text.endsWith('\n'); + + // Detect if Enter was pressed by checking: + // 1. Text ends with newline + // 2. The text before newline matches previous text (Enter was just pressed, not pasted text) + // 3. OR the flag was set by onKeyPress (Android detection) + const isNewlineAdded = hasNewline && text.slice(0, -1) === previousText; + const shouldIntercept = shouldInterceptEnterRef.current || isNewlineAdded; + + if (shouldIntercept && hasNewline) { + const textWithoutNewline = text.slice(0, -1); + const trimmedText = textWithoutNewline.trim(); + + // Reset the flag immediately + shouldInterceptEnterRef.current = false; + + if (trimmedText) { + sendViaEnterKey(textWithoutNewline); + return; + } + + // Empty or whitespace-only: rollback to the previous value (no newline) + setInput(previousText); + return; + } + } + + // Normal flow: update text + previousTextRef.current = text; + textRef.current = text; + debouncedOnChangeText(text); + setInput(text); + }; + const onSelectionChange: TextInputProps['onSelectionChange'] = e => { selectionRef.current = e.nativeEvent.selection; }; @@ -359,34 +422,56 @@ export const ComposerInput = memo( stopAutocomplete(); }, textInputDebounceTime); - const handleTyping = (isTyping: boolean) => { - if (sharing || !rid) return; - dispatch(userTyping(rid, isTyping)); - }; + const handleTyping = (isTyping: boolean) => { + if (sharing || !rid) return; + dispatch(userTyping(rid, isTyping)); + }; - 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 onKeyPress: TextInputProps['onKeyPress'] = e => { + // Only handle Enter key behavior on tablets + if (!isTablet) { + return; + } + + // Check if Enter key was pressed (works reliably on Android, may not fire on iOS for multiline) + // This provides early detection on Android, but onChangeText comparison is the fallback for iOS + const isEnterKey = e.nativeEvent.key === 'Enter' || e.nativeEvent.key === '\n'; + + if (isEnterKey && enterKeyBehavior === 'SEND' && !sharing && rid) { + // Set flag to intercept the newline in onChangeText + // This works on Android where onKeyPress fires reliably + shouldInterceptEnterRef.current = true; + } else { + // Reset flag for other keys + shouldInterceptEnterRef.current = false; + } + // If setting is NEW_LINE, default behavior (multiline) will handle it + }; + + return ( + { + inputRef.current = component; + }} + blurOnSubmit={false} + onChangeText={onChangeText} + onTouchStart={onTouchStart} + onSelectionChange={onSelectionChange} + onFocus={onFocus} + onBlur={onBlur} + onKeyPress={onKeyPress} + 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' : ''}`} + /> + ); }) ); diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index a8157a29b8a..01d8399f372 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -793,6 +793,9 @@ "Send_crash_report": "Send crash report", "Send_email_confirmation": "Send email confirmation", "Send_message": "Send message", + "Enter_key_behavior": "Enter key behavior", + "Enter_key_send_message": "Send message", + "Enter_key_new_line": "New line", "Send_to": "Send to...", "sending_email_confirmation": "sending email confirmation", "Sending_to": "Sending to", diff --git a/app/lib/constants/keys.ts b/app/lib/constants/keys.ts index 8c6dc1da7d2..ccf453ed2b3 100644 --- a/app/lib/constants/keys.ts +++ b/app/lib/constants/keys.ts @@ -20,6 +20,7 @@ export const USER_MENTIONS_PREFERENCES_KEY = 'RC_USER_MENTIONS_PREFERENCES_KEY'; export const ROOM_MENTIONS_PREFERENCES_KEY = 'RC_ROOM_MENTIONS_PREFERENCES_KEY'; export const AUTOPLAY_GIFS_PREFERENCES_KEY = 'RC_AUTOPLAY_GIFS_PREFERENCES_KEY'; export const ALERT_DISPLAY_TYPE_PREFERENCES_KEY = 'RC_ALERT_DISPLAY_TYPE_PREFERENCES_KEY'; +export const ENTER_KEY_BEHAVIOR_PREFERENCES_KEY = 'RC_ENTER_KEY_BEHAVIOR_PREFERENCES_KEY'; export const CRASH_REPORT_KEY = 'RC_CRASH_REPORT_KEY'; export const ANALYTICS_EVENTS_KEY = 'RC_ANALYTICS_EVENTS_KEY'; export const TOKEN_KEY = 'reactnativemeteor_usertoken'; diff --git a/app/views/AccessibilityAndAppearanceView/components/ListPicker.tsx b/app/views/AccessibilityAndAppearanceView/components/ListPicker.tsx index b14e8302461..eedf6d9736b 100644 --- a/app/views/AccessibilityAndAppearanceView/components/ListPicker.tsx +++ b/app/views/AccessibilityAndAppearanceView/components/ListPicker.tsx @@ -7,7 +7,7 @@ import * as List from '../../../containers/List'; import I18n from '../../../i18n'; import { useTheme } from '../../../theme'; import sharedStyles from '../../Styles'; -import { type TAlertDisplayType } from '..'; +import { type TAlertDisplayType, type TEnterKeyBehavior } from '..'; const styles = StyleSheet.create({ leftTitleContainer: { @@ -35,24 +35,27 @@ const styles = StyleSheet.create({ } }); -type TOPTIONS = { label: string; value: TAlertDisplayType; description: string | null }[]; +type TAlertOptions = { label: string; value: TAlertDisplayType; description: string | null }[]; +type TEnterKeyOptions = { label: string; value: TEnterKeyBehavior; description: string | null }[]; -interface IBaseParams { - value: TAlertDisplayType; - onChangeValue: (value: TAlertDisplayType) => void; +interface IBaseParams { + value: T; + onChangeValue: (value: T) => void; } -const ListPicker = ({ +const ListPicker = ({ value, title, - onChangeValue + onChangeValue, + type }: { title: string; -} & IBaseParams) => { + type?: 'alert' | 'enterKey'; +} & IBaseParams) => { const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); - const OPTIONS: TOPTIONS = [ + const ALERT_OPTIONS: TAlertOptions = [ { label: I18n.t('A11y_appearance_toasts'), value: 'TOAST' as TAlertDisplayType, @@ -65,7 +68,22 @@ const ListPicker = ({ } ]; - const option = OPTIONS.find(option => option.value === value) || OPTIONS[0]; + const ENTER_KEY_OPTIONS: TEnterKeyOptions = [ + { + label: I18n.t('Enter_key_send_message'), + value: 'SEND' as TEnterKeyBehavior, + description: null + }, + { + label: I18n.t('Enter_key_new_line'), + value: 'NEW_LINE' as TEnterKeyBehavior, + description: null + } + ]; + + const OPTIONS = type === 'enterKey' ? ENTER_KEY_OPTIONS : ALERT_OPTIONS; + + const option = OPTIONS.find(option => option.value === value) || (OPTIONS[0] as typeof OPTIONS[0]); const getOptions = (): TActionSheetOptionsItem[] => OPTIONS.map(i => ({ @@ -76,7 +94,7 @@ const ListPicker = ({ }`, onPress: () => { hideActionSheet(); - onChangeValue(i.value); + onChangeValue(i.value as T); }, right: option?.value === i.value ? () => : undefined })); diff --git a/app/views/AccessibilityAndAppearanceView/index.tsx b/app/views/AccessibilityAndAppearanceView/index.tsx index b5d5b39a7cc..b1093cde0a4 100644 --- a/app/views/AccessibilityAndAppearanceView/index.tsx +++ b/app/views/AccessibilityAndAppearanceView/index.tsx @@ -14,11 +14,14 @@ import { USER_MENTIONS_PREFERENCES_KEY, ROOM_MENTIONS_PREFERENCES_KEY, AUTOPLAY_GIFS_PREFERENCES_KEY, - ALERT_DISPLAY_TYPE_PREFERENCES_KEY + ALERT_DISPLAY_TYPE_PREFERENCES_KEY, + ENTER_KEY_BEHAVIOR_PREFERENCES_KEY } from '../../lib/constants/keys'; +import { isTablet } from '../../lib/methods/helpers/deviceInfo'; import ListPicker from './components/ListPicker'; export type TAlertDisplayType = 'TOAST' | 'DIALOG'; +export type TEnterKeyBehavior = 'SEND' | 'NEW_LINE'; const AccessibilityAndAppearanceView = () => { const navigation = useNavigation>(); @@ -30,6 +33,10 @@ const AccessibilityAndAppearanceView = () => { ALERT_DISPLAY_TYPE_PREFERENCES_KEY, 'TOAST' ); + const [enterKeyBehavior, setEnterKeyBehavior] = useUserPreferences( + ENTER_KEY_BEHAVIOR_PREFERENCES_KEY, + 'NEW_LINE' + ); const toggleMentionsWithAtSymbol = () => { setMentionsWithAtSymbol(!mentionsWithAtSymbol); @@ -57,7 +64,7 @@ const AccessibilityAndAppearanceView = () => { ? undefined : () => }); - }, []); + }, [navigation, isMasterDetail]); return ( @@ -116,6 +123,20 @@ const AccessibilityAndAppearanceView = () => { /> + {isTablet ? ( + + + { + setEnterKeyBehavior(value); + }} + title={I18n.t('Enter_key_behavior')} + value={enterKeyBehavior} + type='enterKey' + /> + + + ) : null} ); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b443f1d65a8..95ecf7f2e5d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3197,9 +3197,9 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 WatermelonDB: 4c846c8cb94eef3cba90fa034d15310163226703 - Yoga: dfabf1234ccd5ac41d1b1d43179f024366ae9831 + Yoga: 2a3a4c38a8441b6359d5e5914d35db7b2b67aebd ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 PODFILE CHECKSUM: 4c73563b34520b90c036817cdb9ccf65fea5f5c5 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 7f679cbc227..78155a99405 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -1734,7 +1734,7 @@ inputFileListPaths = ( ); inputPaths = ( - "$TARGET_BUILD_DIR/$INFOPLIST_PATH", + $TARGET_BUILD_DIR/$INFOPLIST_PATH, ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -1820,7 +1820,7 @@ inputFileListPaths = ( ); inputPaths = ( - "$TARGET_BUILD_DIR/$INFOPLIST_PATH", + $TARGET_BUILD_DIR/$INFOPLIST_PATH, ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -2602,7 +2602,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - "$PODS_CONFIGURATION_BUILD_DIR/Firebase", + $PODS_CONFIGURATION_BUILD_DIR/Firebase, "$(SRCROOT)/../node_modules/react-native-mmkv-storage/ios/**", ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; @@ -2679,7 +2679,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - "$PODS_CONFIGURATION_BUILD_DIR/Firebase", + $PODS_CONFIGURATION_BUILD_DIR/Firebase, "$(SRCROOT)/../node_modules/react-native-mmkv-storage/ios/**", ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist;