Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 118 additions & 33 deletions app/containers/MessageComposer/components/ComposerInput.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 };

Expand All @@ -55,8 +57,13 @@ export const ComposerInput = memo(
const textRef = React.useRef('');
const firstRender = React.useRef(true);
const selectionRef = React.useRef<IInputSelection>(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) });
Expand Down Expand Up @@ -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
Expand All @@ -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;
};
Expand Down Expand Up @@ -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 (
<TextInput
style={[styles.textInput, { color: colors.fontDefault }]}
placeholder={placeholder}
placeholderTextColor={colors.fontAnnotation}
ref={component => {
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 (
<TextInput
style={[styles.textInput, { color: colors.fontDefault }]}
placeholder={placeholder}
placeholderTextColor={colors.fontAnnotation}
ref={component => {
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' : ''}`}
/>
);
})
);

Expand Down
3 changes: 3 additions & 0 deletions app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions app/lib/constants/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
40 changes: 29 additions & 11 deletions app/views/AccessibilityAndAppearanceView/components/ListPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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<T> {
value: T;
onChangeValue: (value: T) => void;
}

const ListPicker = ({
const ListPicker = <T extends TAlertDisplayType | TEnterKeyBehavior>({
value,
title,
onChangeValue
onChangeValue,
type
}: {
title: string;
} & IBaseParams) => {
type?: 'alert' | 'enterKey';
} & IBaseParams<T>) => {
const { showActionSheet, hideActionSheet } = useActionSheet();
const { colors } = useTheme();

const OPTIONS: TOPTIONS = [
const ALERT_OPTIONS: TAlertOptions = [
{
label: I18n.t('A11y_appearance_toasts'),
value: 'TOAST' as TAlertDisplayType,
Expand All @@ -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 => ({
Expand All @@ -76,7 +94,7 @@ const ListPicker = ({
}`,
onPress: () => {
hideActionSheet();
onChangeValue(i.value);
onChangeValue(i.value as T);
},
right: option?.value === i.value ? () => <CustomIcon name={'check'} size={20} color={colors.strokeHighlight} /> : undefined
}));
Expand Down
25 changes: 23 additions & 2 deletions app/views/AccessibilityAndAppearanceView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<NativeStackNavigationProp<AccessibilityStackParamList>>();
Expand All @@ -30,6 +33,10 @@ const AccessibilityAndAppearanceView = () => {
ALERT_DISPLAY_TYPE_PREFERENCES_KEY,
'TOAST'
);
const [enterKeyBehavior, setEnterKeyBehavior] = useUserPreferences<TEnterKeyBehavior>(
ENTER_KEY_BEHAVIOR_PREFERENCES_KEY,
'NEW_LINE'
);

const toggleMentionsWithAtSymbol = () => {
setMentionsWithAtSymbol(!mentionsWithAtSymbol);
Expand Down Expand Up @@ -57,7 +64,7 @@ const AccessibilityAndAppearanceView = () => {
? undefined
: () => <HeaderButton.Drawer navigation={navigation} testID='accessibility-view-drawer' />
});
}, []);
}, [navigation, isMasterDetail]);
return (
<SafeAreaView>
<List.Container testID='accessibility-view-list'>
Expand Down Expand Up @@ -116,6 +123,20 @@ const AccessibilityAndAppearanceView = () => {
/>
<List.Separator />
</List.Section>
{isTablet ? (
<List.Section>
<List.Separator />
<ListPicker
onChangeValue={value => {
setEnterKeyBehavior(value);
}}
title={I18n.t('Enter_key_behavior')}
value={enterKeyBehavior}
type='enterKey'
/>
<List.Separator />
</List.Section>
) : null}
</List.Container>
</SafeAreaView>
);
Expand Down
4 changes: 2 additions & 2 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading