Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NoQA] e2e fix keyboard not opening #50711

Merged
merged 7 commits into from
Oct 15, 2024
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
2 changes: 1 addition & 1 deletion .github/workflows/e2ePerformanceTests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
uses: ./.github/actions/javascript/getArtifactInfo
with:
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }}
ARTIFACT_NAME: baseline-${{ steps.getMostRecentRelease.outputs.VERSION }}-android-artifact-apk
ARTIFACT_NAME: baseline-${{ steps.getMostRecentRelease.outputs.VERSION }}android-artifact-apk

- name: Skip build if there's already an existing artifact for the baseline
if: ${{ fromJSON(steps.checkForExistingArtifact.outputs.ARTIFACT_FOUND) }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native';
import {StyleSheet} from 'react-native';
import type {FileObject} from '@components/AttachmentModal';
import type {ComposerProps} from '@components/Composer/types';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
Expand All @@ -17,7 +18,6 @@ import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullCompo
import * as EmojiUtils from '@libs/EmojiUtils';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import CONST from '@src/CONST';
import type {ComposerProps} from './types';

const excludeNoStyles: Array<keyof MarkdownStyle> = [];
const excludeReportMentionStyle: Array<keyof MarkdownStyle> = ['mentionReport'];
Expand Down
368 changes: 368 additions & 0 deletions src/components/Composer/implementation/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
import type {MarkdownStyle} from '@expensify/react-native-live-markdown';
import lodashDebounce from 'lodash/debounce';
import type {BaseSyntheticEvent, ForwardedRef} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native';
import {DeviceEventEmitter, StyleSheet} from 'react-native';
import type {ComposerProps} from '@components/Composer/types';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
import useHtmlPaste from '@hooks/useHtmlPaste';
import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible';
import useMarkdownStyle from '@hooks/useMarkdownStyle';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
import * as Browser from '@libs/Browser';
import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable';
import * as EmojiUtils from '@libs/EmojiUtils';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition';
import CONST from '@src/CONST';

const excludeNoStyles: Array<keyof MarkdownStyle> = [];
const excludeReportMentionStyle: Array<keyof MarkdownStyle> = ['mentionReport'];
const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL];

// Enable Markdown parsing.
// On web we like to have the Text Input field always focused so the user can easily type a new chat
function Composer(
{
value,
defaultValue,
maxLines = -1,
onKeyPress = () => {},
style,
autoFocus = false,
shouldCalculateCaretPosition = false,
isDisabled = false,
onClear = () => {},
onPasteFile = () => {},
onSelectionChange = () => {},
setIsFullComposerAvailable = () => {},
checkComposerVisibility = () => false,
selection: selectionProp = {
start: 0,
end: 0,
},
isComposerFullSize = false,
shouldContainScroll = true,
isGroupPolicyReport = false,
...props
}: ComposerProps,
ref: ForwardedRef<TextInput | HTMLInputElement>,
) {
const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]);
const theme = useTheme();
const styles = useThemeStyles();
const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles);
const StyleUtils = useStyleUtils();
const textInput = useRef<AnimatedMarkdownTextInputRef | null>(null);
const [selection, setSelection] = useState<
| {
start: number;
end?: number;
positionX?: number;
positionY?: number;
}
| undefined
>({
start: selectionProp.start,
end: selectionProp.end,
});
const [isRendered, setIsRendered] = useState(false);
const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? '');
const [prevScroll, setPrevScroll] = useState<number | undefined>();
const isReportFlatListScrolling = useRef(false);

useEffect(() => {
if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) {
return;
}
setSelection(selectionProp);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [selectionProp]);

/**
* Adds the cursor position to the selection change event.
*/
const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const webEvent = event as BaseSyntheticEvent<TextInputSelectionChangeEventData>;
const sel = window.getSelection();
if (shouldCalculateCaretPosition && isRendered && sel) {
const range = sel.getRangeAt(0).cloneRange();
range.collapse(true);
const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0];
const containerRect = textInput.current?.getBoundingClientRect();

let x = 0;
let y = 0;
if (rect && containerRect) {
x = rect.left - containerRect.left;
y = rect.top - containerRect.top + (textInput?.current?.scrollTop ?? 0) - rect.height / 2;
}

const selectionValue = {
start: webEvent.nativeEvent.selection.start,
end: webEvent.nativeEvent.selection.end,
positionX: x - CONST.SPACE_CHARACTER_WIDTH,
positionY: y,
};

onSelectionChange({
...webEvent,
nativeEvent: {
...webEvent.nativeEvent,
selection: selectionValue,
},
});
setSelection(selectionValue);
} else {
onSelectionChange(webEvent);
setSelection(webEvent.nativeEvent.selection);
}
};

/**
* Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file,
* Otherwise, convert pasted HTML to Markdown and set it on the composer.
*/
const handlePaste = useCallback(
(event: ClipboardEvent) => {
const isVisible = checkComposerVisibility();
const isFocused = textInput.current?.isFocused();
const isContenteditableDivFocused = document.activeElement?.nodeName === 'DIV' && document.activeElement?.hasAttribute('contenteditable');

if (!(isVisible || isFocused)) {
return true;
}

if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) {
const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | 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 === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true';
if (isTargetInput || (!isFocused && isContenteditableDivFocused && event.clipboardData?.files.length)) {
return true;
}

textInput.current?.focus();
}

event.preventDefault();

const TEXT_HTML = 'text/html';

const clipboardDataHtml = event.clipboardData?.getData(TEXT_HTML) ?? '';

// If paste contains files, then trigger file management
if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) {
// Prevent the default so we do not post the file name into the text box
onPasteFile(event.clipboardData.files[0]);
return true;
}

// If paste contains base64 image
if (clipboardDataHtml?.includes(CONST.IMAGE_BASE64_MATCH)) {
const domparser = new DOMParser();
const pastedHTML = clipboardDataHtml;
const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML)?.images;

if (embeddedImages.length > 0 && embeddedImages[0].src) {
const src = embeddedImages[0].src;
const file = FileUtils.base64ToFile(src, 'image.png');
onPasteFile(file);
return true;
}
}

// If paste contains image from Google Workspaces ex: Sheets, Docs, Slide, etc
if (clipboardDataHtml?.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) {
const domparser = new DOMParser();
const pastedHTML = clipboardDataHtml;
const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images;

if (embeddedImages.length > 0 && embeddedImages[0]?.src) {
const src = embeddedImages[0].src;
if (src.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) {
fetch(src)
.then((response) => response.blob())
.then((blob) => {
const file = new File([blob], 'image.jpg', {type: 'image/jpeg'});
onPasteFile(file);
});
return true;
}
}
}
return false;
},
[onPasteFile, checkComposerVisibility],
);

useEffect(() => {
if (!textInput.current) {
return;
}
const debouncedSetPrevScroll = lodashDebounce(() => {
if (!textInput.current) {
return;
}
setPrevScroll(textInput.current.scrollTop);
}, 100);

textInput.current.addEventListener('scroll', debouncedSetPrevScroll);
return () => {
textInput.current?.removeEventListener('scroll', debouncedSetPrevScroll);
};
}, []);

useEffect(() => {
const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => {
isReportFlatListScrolling.current = scrolling;
});

return () => scrollingListener.remove();
}, []);

useEffect(() => {
const handleWheel = (e: MouseEvent) => {
if (isReportFlatListScrolling.current) {
e.preventDefault();
return;
}
e.stopPropagation();
};
textInput.current?.addEventListener('wheel', handleWheel, {passive: false});

return () => {
textInput.current?.removeEventListener('wheel', handleWheel);
};
}, []);

useEffect(() => {
if (!textInput.current || prevScroll === undefined) {
return;
}
// eslint-disable-next-line react-compiler/react-compiler
textInput.current.scrollTop = prevScroll;
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [isComposerFullSize]);

useHtmlPaste(textInput, handlePaste, true);

useEffect(() => {
setIsRendered(true);
}, []);

const clear = useCallback(() => {
if (!textInput.current) {
return;
}

const currentText = textInput.current.value;
textInput.current.clear();

// We need to reset the selection to 0,0 manually after clearing the text input on web
const selectionEvent = {
nativeEvent: {
selection: {
start: 0,
end: 0,
},
},
} as NativeSyntheticEvent<TextInputSelectionChangeEventData>;
onSelectionChange(selectionEvent);
setSelection({start: 0, end: 0});

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

useImperativeHandle(
ref,
() => {
const textInputRef = textInput.current;
if (!textInputRef) {
throw new Error('textInputRef is not available. This should never happen and indicates a developer error.');
}

return {
...textInputRef,
// Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works
clear,
// We have to redefine these methods as they are inherited by prototype chain and are not accessible directly
blur: () => textInputRef.blur(),
focus: () => textInputRef.focus(),
get scrollTop() {
return textInputRef.scrollTop;
},
};
},
[clear],
);

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

onKeyPress(e);
},
[onKeyPress],
);

const scrollStyleMemo = useMemo(() => {
if (shouldContainScroll) {
return isScrollBarVisible ? [styles.overflowScroll, styles.overscrollBehaviorContain] : styles.overflowHidden;
}
return styles.overflowAuto;
}, [shouldContainScroll, styles.overflowAuto, styles.overflowScroll, styles.overscrollBehaviorContain, styles.overflowHidden, isScrollBarVisible]);

const inputStyleMemo = useMemo(
() => [
StyleSheet.flatten([style, {outline: 'none'}]),
StyleUtils.getComposeTextAreaPadding(isComposerFullSize),
Browser.isMobileSafari() || Browser.isSafari() ? styles.rtlTextRenderForSafari : {},
scrollStyleMemo,
StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize),
isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined,
textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {},
],

[style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis],
);

return (
<RNMarkdownTextInput
id={CONST.COMPOSER.NATIVE_ID}
autoComplete="off"
autoCorrect={!Browser.isMobileSafari()}
placeholderTextColor={theme.placeholderText}
ref={(el) => (textInput.current = el)}
selection={selection}
style={[inputStyleMemo]}
markdownStyle={markdownStyle}
value={value}
defaultValue={defaultValue}
autoFocus={autoFocus}
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
onSelectionChange={addCursorPositionToSelectionChange}
onContentSizeChange={(e) => {
updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles);
}}
disabled={isDisabled}
onKeyPress={handleKeyPress}
addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL}
imagePreviewAuthRequiredURLs={imagePreviewAuthRequiredURLs}
/>
);
}

Composer.displayName = 'Composer';

export default React.forwardRef(Composer);
Loading
Loading