Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/components/Composer/implementation/index.native.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {ComposerProps, ComposerRef} from '@components/Composer/types';
import type {ComposerProps} from '@components/Composer/types';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';

Expand Down Expand Up @@ -97,7 +97,7 @@ function Composer({
// get a ref to the inner textInput element e.g. if we do
// <constructor ref={el => this.textInput = el} /> this will not
// return a ref to the component, but rather the HTML element by default
ref(textInputRef.current as ComposerRef);
ref(textInputRef.current);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isInLandscapeMode],
Expand Down
25 changes: 15 additions & 10 deletions src/components/Composer/implementation/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {ComposerProps, ComposerRef} from '@components/Composer/types';
import type {ComposerProps, WebComposerRef} from '@components/Composer/types';
import {useSession} from '@components/OnyxListItemProvider';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
Expand All @@ -20,13 +20,15 @@ import Parser from '@libs/Parser';
import CONST from '@src/CONST';

import type {MarkdownStyle} from '@expensify/react-native-live-markdown';
import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native';
import type {TextInput, TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native';

import {useIsFocused} from '@react-navigation/native';
import lodashDebounce from 'lodash/debounce';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, StyleSheet} from 'react-native';

type WebMarkdownInput = HTMLDivElement & Pick<TextInput, 'clear' | 'isFocused'> & {value?: string};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/components/Composer/implementation/index.tsx L30 / L295

🟠 value?: string on WebMarkdownInput is what forces onClear to become (text: string | undefined) => void across types.ts, ComposerWithSuggestions and useComposerSubmit, and that's why validateAndSubmitDraft now has to throw by hand. Can we normalize it at the source instead ?

const currentText = textInputRef.current.value ?? '';

Then onClear can stay (text: string) => void everywhere, and the undefined branch plus the hand-written TypeError in useComposerSubmit can go.

Right now, a clear fired before live-markdown sets value still crashes the report screen. The only difference is that the crash is now deliberate.


const excludeNoStyles: Array<keyof MarkdownStyle> = [];
const excludeReportMentionStyle: Array<keyof MarkdownStyle> = ['mentionReport'];
const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL, CONST.QA_EXPENSIFY_URL];
Expand Down Expand Up @@ -69,7 +71,7 @@ function Composer({
const addAuthTokenToImageURL = useCallback((url: string) => addEncryptedAuthTokenToURL(url, encryptedAuthToken), [encryptedAuthToken]);
const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles);
const StyleUtils = useStyleUtils();
const textInputRef = useRef<AnimatedMarkdownTextInputRef | null>(null);
const textInputRef = useRef<WebMarkdownInput | null>(null);
const [selection, setSelection] = useState<
| {
start: number;
Expand Down Expand Up @@ -156,7 +158,7 @@ function Composer({
}

if (textInputRef.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) {
const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null;
const eventTarget = event.target instanceof HTMLElement ? event.target : null;
// To make sure the composer does not capture paste events from other inputs, we check where the event originated
// If it did originate in another input, we return early to prevent the composer from handling the paste
const isTargetInput = eventTarget?.nodeName === CONST.ELEMENT_NAME.INPUT || eventTarget?.nodeName === CONST.ELEMENT_NAME.TEXTAREA || eventTarget?.contentEditable === 'true';
Expand Down Expand Up @@ -290,7 +292,7 @@ function Composer({
return;
}

const currentText = textInputRef.current.value;
const currentText = textInputRef.current.value ?? '';
textInputRef.current.clear();

// We need to reset the selection to 0,0 manually after clearing the text input on web
Expand All @@ -301,14 +303,14 @@ function Composer({
end: 0,
},
},
} as TextInputSelectionChangeEvent;
};
onSelectionChange(selectionEvent);
setSelection({start: 0, end: 0});

onClear(currentText);
}, [onClear, onSelectionChange]);

useImperativeHandle(ref, () => {
useImperativeHandle(ref, (): WebComposerRef => {
const textInput = textInputRef.current;
if (!textInput) {
throw new Error('textInput is not available. This should never happen and indicates a developer error.');
Expand All @@ -321,16 +323,17 @@ function Composer({
// We have to redefine these methods as they are inherited by prototype chain and are not accessible directly
blur: () => textInput.blur(),
focus: () => textInput.focus(),
isFocused: () => textInput.isFocused(),
get scrollTop() {
return textInput.scrollTop;
},
} as ComposerRef;
};
}, [clear]);

const handleKeyPress = useCallback(
(e: TextInputKeyPressEvent) => {
// Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed
if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) {
if (!onKeyPress || (e.nativeEvent instanceof KeyboardEvent && isEnterWhileComposition(e.nativeEvent))) {
return;
}

Expand Down Expand Up @@ -366,7 +369,9 @@ function Composer({
autoComplete="off"
autoCorrect={!isMobileSafari()}
placeholderTextColor={theme.placeholderText}
ref={textInputRef}
ref={(element: AnimatedMarkdownTextInputRef | null) => {
textInputRef.current = element instanceof HTMLDivElement ? element : null;
}}
selection={selection}
style={[inputStyleMemo]}
markdownStyle={markdownStyle}
Expand Down
25 changes: 16 additions & 9 deletions src/components/Composer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,31 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types';

import type {FileObject} from '@src/types/utils/Attachment';

import type {MarkdownTextInput} from '@expensify/react-native-live-markdown';
import type {Ref} from 'react';
import type {StyleProp, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native';
import type {StyleProp, TextInput, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native';

type TextSelection = {
start: number;
end?: number;
positionX?: number;
positionY?: number;
};
type CustomSelectionChangeEvent = TextInputSelectionChangeEvent & {
positionX?: number;
positionY?: number;
type CustomSelectionChangeEvent = {
nativeEvent: {
selection: TextInputSelectionChangeEvent['nativeEvent']['selection'] & Pick<TextSelection, 'positionX' | 'positionY'>;
};
};

type WebComposerRef = Pick<TextInput, 'clear' | 'blur' | 'focus' | 'isFocused'> & {
readonly scrollTop: number;
setSelection?: never;
setNativeProps?: never;
preventFocusOnFirstResponderOnce?: never;
};

type ComposerRef = MarkdownTextInput & HTMLInputElement & HTMLTextAreaElement;
type ComposerRef = TextInput | WebComposerRef;

type ComposerProps = Omit<TextInputProps, 'onClear'> &
type ComposerProps = Omit<TextInputProps, 'onClear' | 'onSelectionChange'> &
ForwardedFSClassProps & {
/** Indicate whether input is multiline */
multiline?: boolean;
Expand All @@ -35,7 +42,7 @@ type ComposerProps = Omit<TextInputProps, 'onClear'> &

/**
* Callback when the input was cleared using the .clear ref method.
* The text parameter will be the value of the text that was cleared.
* The text parameter is empty if live-markdown has not initialized its value.
*/
onClear?: (text: string) => void;

Expand Down Expand Up @@ -77,4 +84,4 @@ type ComposerProps = Omit<TextInputProps, 'onClear'> &
ref?: Ref<ComposerRef>;
};

export type {TextSelection, ComposerProps, CustomSelectionChangeEvent, ComposerRef};
export type {TextSelection, ComposerProps, CustomSelectionChangeEvent, ComposerRef, WebComposerRef};
14 changes: 9 additions & 5 deletions src/hooks/useHtmlPaste/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,17 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive
const paste = useCallback(
(text: string) => {
try {
const textInputHTMLElement = textInputRef.current as HTMLElement;
if (textInputHTMLElement?.hasAttribute('contenteditable')) {
const textInputHTMLElement = textInputRef.current;
if (!(textInputHTMLElement instanceof HTMLElement)) {
return;
}
if (textInputHTMLElement.hasAttribute('contenteditable')) {
insertAtCaret(textInputHTMLElement, text, maxLength);
} else if (textInputHTMLElement instanceof HTMLInputElement || textInputHTMLElement instanceof HTMLTextAreaElement) {
const availableLength = maxLength - (textInputHTMLElement.value?.length ?? 0);
textInputHTMLElement.setRangeText(text.slice(0, availableLength));
} else {
const htmlInput = textInputRef.current as unknown as HTMLInputElement;
const availableLength = maxLength - (htmlInput.value?.length ?? 0);
htmlInput.setRangeText(text.slice(0, availableLength));
return;
}

requestAnimationFrame(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useHtmlPaste/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type {ComponentRef, RefObject} from 'react';
import type {TextInput} from 'react-native';

type UseHtmlPaste = (
textInputRef: RefObject<(HTMLTextAreaElement & ComponentRef<typeof TextInput>) | ComponentRef<typeof TextInput> | null>,
textInputRef: RefObject<(HTMLElement & Pick<TextInput, 'isFocused'>) | ComponentRef<typeof TextInput> | null>,
preHtmlPasteCallback?: (event: ClipboardEvent) => boolean,
isActive?: boolean,
maxLength?: number, // Maximum length of the text input value after pasting
Expand Down
6 changes: 4 additions & 2 deletions src/libs/ComponentUtils/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {Component} from 'react';
import type {ComposerRef} from '@components/Composer/types';

import type {HostInstance} from 'react-native';
import type {AnimatedRef} from 'react-native-reanimated';

import {dispatchCommand} from 'react-native-reanimated';
Expand All @@ -11,7 +13,7 @@ const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'none';
* Clears a text input on the UI thread using a custom clear command
* that bypasses the event count check.
*/
function forceClearInput(animatedInputRef: AnimatedRef<Component>) {
function forceClearInput<TRef extends HostInstance | ComposerRef>(animatedInputRef: AnimatedRef<TRef>) {
'worklet';

dispatchCommand(animatedInputRef, 'clear');
Expand Down
4 changes: 3 additions & 1 deletion src/libs/ComponentUtils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type {ComposerRef} from '@components/Composer/types';

import ObjectUtils from '@src/types/utils/ObjectUtils';

import type {HostInstance} from 'react-native';
Expand All @@ -10,7 +12,7 @@ import type AccessibilityRoleForm from './types';
*/
const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'form';

function forceClearInput(animatedInputRef: AnimatedRef<HostInstance>) {
function forceClearInput<TRef extends HostInstance | ComposerRef>(animatedInputRef: AnimatedRef<TRef>) {
'worklet';

const input = animatedInputRef.current;
Expand Down
9 changes: 5 additions & 4 deletions src/libs/InputUtils/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import type {MoveSelectionToEnd, ScrollInput} from './types';

const scrollToBottom: ScrollInput = (input) => {
if (!('scrollTop' in input)) {
// Native inputs also expose scroll offsets, but only DOM offsets are writable.
if (!(input instanceof HTMLElement)) {
return;
}
// eslint-disable-next-line no-param-reassign
(input as HTMLInputElement).scrollTop = input.scrollHeight;
input.scrollTop = input.scrollHeight;
};

const scrollToRight: ScrollInput = (input) => {
if (!('scrollLeft' in input)) {
if (!(input instanceof HTMLElement)) {
return;
}
// Scroll to the far right
// eslint-disable-next-line no-param-reassign
(input as HTMLInputElement).scrollLeft = input.scrollWidth;
input.scrollLeft = input.scrollWidth;
};

const moveSelectionToEnd: MoveSelectionToEnd = (input) => {
Expand Down
2 changes: 1 addition & 1 deletion src/libs/InputUtils/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {ComponentRef} from 'react';
import type {TextInput} from 'react-native';

type ScrollInput = (input: HTMLInputElement | ComponentRef<typeof TextInput>) => void;
type ScrollInput = (input: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement | ComponentRef<typeof TextInput>) => void;
type MoveSelectionToEnd = (input: HTMLInputElement | ComponentRef<typeof TextInput>) => void;

export type {ScrollInput, MoveSelectionToEnd};
Loading
Loading