From 49d2c436bd98f6760a4a09817fc93ab5e53a8680 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 09:17:47 -0800 Subject: [PATCH 01/29] Initial work --- src/components/CommandSuggestions.tsx | 84 +++++++ .../ReportActionCompose.tsx | 3 +- .../ReportActionCompose/SuggestionCommand.tsx | 207 ++++++++++++++++++ .../ReportActionCompose/Suggestions.tsx | 28 ++- 4 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 src/components/CommandSuggestions.tsx create mode 100644 src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx new file mode 100644 index 000000000000..8d091f7dd150 --- /dev/null +++ b/src/components/CommandSuggestions.tsx @@ -0,0 +1,84 @@ +import type {ReactElement} from 'react'; +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {Command} from '@pages/home/report/ReportActionCompose/SuggestionCommand'; +import AutoCompleteSuggestions from './AutoCompleteSuggestions'; +import type {MeasureParentContainerAndCursorCallback} from './AutoCompleteSuggestions/types'; +import Text from './Text'; + +type CommandSuggestionsProps = { + /** The index of the highlighted command */ + highlightedCommandIndex?: number; + + /** Array of suggested command */ + commands: Command[]; + + /** Fired when the user selects an command */ + onSelect: (index: number) => void; + + /** Measures the parent container's position and dimensions. Also add cursor coordinates */ + measureParentContainerAndReportCursor: (callback: MeasureParentContainerAndCursorCallback) => void; + + /** Reset the command suggestions */ + resetSuggestions: () => void; +}; + +/** + * Create unique keys for each command item + */ +const keyExtractor = (item: Command, index: number): string => `${item.name}+${index}}`; + +function CommandSuggestions({commands, onSelect, highlightedCommandIndex = 0, measureParentContainerAndReportCursor = () => {}, resetSuggestions}: CommandSuggestionsProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + /** + * Render an command suggestion menu item component. + */ + const renderSuggestionMenuItem = useCallback( + (item: Command): ReactElement => { + const styledTextArray = [{text: 'test', isColored: false}]; + + return ( + + test + + : + {styledTextArray.map(({text, isColored}) => ( + + {text} + + ))} + : + + + ); + }, + [styles.autoCompleteSuggestionContainer, styles.emojiSuggestionsEmoji, styles.emojiSuggestionsText, StyleUtils], + ); + + return ( + + ); +} + +CommandSuggestions.displayName = 'CommandSuggestions'; + +export default CommandSuggestions; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 0a461bdf756a..f91a156b4afc 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -54,6 +54,7 @@ import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef, ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions'; import SendButton from './SendButton'; +import {Command} from './SuggestionCommand'; type SuggestionsRef = { resetSuggestions: () => void; @@ -61,7 +62,7 @@ type SuggestionsRef = { triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; - getSuggestions: () => Mention[] | Emoji[]; + getSuggestions: () => Mention[] | Emoji[] | Command[]; getIsSuggestionsMenuVisible: () => boolean; }; diff --git a/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx new file mode 100644 index 000000000000..d7fc67dc6d66 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx @@ -0,0 +1,207 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import CommandSuggestions from '@components/CommandSuggestions'; +import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import useLocalize from '@hooks/useLocalize'; +import * as SuggestionsUtils from '@libs/SuggestionUtils'; +import CONST from '@src/CONST'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {SuggestionsRef} from './ReportActionCompose'; +import type {SuggestionProps} from './Suggestions'; + +type Command = { + code: string; + description: string; +}; + +type SuggestionsValue = { + suggestedCommands: Command[]; + shouldShowSuggestionMenu: boolean; +}; + +type SuggestionCommandProps = SuggestionProps & { + /** Function to clear the input */ + resetKeyboardInput?: () => void; +}; + +const defaultSuggestionsValues: SuggestionsValue = { + suggestedCommands: [], + shouldShowSuggestionMenu: false, +}; + +function SuggestionCommand( + {value, selection, setSelection, updateComment, resetKeyboardInput, measureParentContainerAndReportCursor, isComposerFocused}: SuggestionCommandProps, + ref: ForwardedRef, +) { + const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); + const suggestionValuesRef = useRef(suggestionValues); + // eslint-disable-next-line react-compiler/react-compiler + suggestionValuesRef.current = suggestionValues; + + const isCommandSuggestionsMenuVisible = suggestionValues.suggestedCommands.length > 0 && suggestionValues.shouldShowSuggestionMenu; + + const [highlightedCommandIndex, setHighlightedCommandIndex] = useArrowKeyFocusManager({ + isActive: isCommandSuggestionsMenuVisible, + maxIndex: suggestionValues.suggestedCommands.length - 1, + shouldExcludeTextAreaNodes: false, + }); + + // Used to decide whether to block the suggestions list from showing to prevent flickering + const shouldBlockCalc = useRef(false); + + /** + * Replace the code of command and update selection + * @param {Number} selectedCommand + */ + const insertSelectedCommand = useCallback( + (commandIndex: number) => { + const commandObj = commandIndex !== -1 ? suggestionValues.suggestedCommands.at(commandIndex) : undefined; + const commandCode = commandObj?.code; + const commentAfterColonWithCommandNameRemoved = value.slice(selection.end); + + updateComment(`${commandCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithCommandNameRemoved)}`, true); + + // In some Android phones keyboard, the text to search for the command is not cleared + // will be added after the user starts typing again on the keyboard. This package is + // a workaround to reset the keyboard natively. + resetKeyboardInput?.(); + + setSelection({ + start: (commandCode?.length ?? 0) + CONST.SPACE_LENGTH, + end: (commandCode?.length ?? 0) + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({...prevState, suggestedCommands: []})); + }, + [resetKeyboardInput, selection.end, setSelection, suggestionValues.suggestedCommands, updateComment, value], + ); + + /** + * Clean data related to suggestions + */ + const resetSuggestions = useCallback(() => { + setSuggestionValues(defaultSuggestionsValues); + }, []); + + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + setSuggestionValues((prevState) => { + if (prevState.shouldShowSuggestionMenu) { + return {...prevState, shouldShowSuggestionMenu: false}; + } + return prevState; + }); + }, []); + + /** + * Listens for keyboard shortcuts and applies the action + */ + const triggerHotkeyActions = useCallback( + (e: KeyboardEvent) => { + const suggestionsExist = suggestionValues.suggestedCommands.length > 0; + + if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + e.preventDefault(); + if (suggestionValues.suggestedCommands.length > 0) { + insertSelectedCommand(highlightedCommandIndex); + } + return true; + } + + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + e.preventDefault(); + + if (suggestionsExist) { + resetSuggestions(); + } + + return true; + } + }, + [highlightedCommandIndex, insertSelectedCommand, resetSuggestions, suggestionValues.suggestedCommands.length], + ); + + /** + * Calculates and cares about the content of an Command Suggester + */ + const calculateCommandSuggestion = useCallback( + (newValue: string, selectionStart?: number, selectionEnd?: number) => { + if (selectionStart !== selectionEnd || !selectionEnd || shouldBlockCalc.current || !newValue || (selectionStart === 0 && selectionEnd === 0)) { + shouldBlockCalc.current = false; + resetSuggestions(); + return; + } + const isCurrentlyShowingCommandSuggestion = newValue.startsWith('/'); + + const nextState: SuggestionsValue = { + suggestedCommands: [], + shouldShowSuggestionMenu: false, + }; + const newSuggestedCommands = []; + + if (newSuggestedCommands?.length && isCurrentlyShowingCommandSuggestion) { + nextState.suggestedCommands = newSuggestedCommands; + nextState.shouldShowSuggestionMenu = !isEmptyObject(newSuggestedCommands); + } + + // Early return if there is no update + const currentState = suggestionValuesRef.current; + if (nextState.suggestedCommands.length === 0 && currentState.suggestedCommands.length === 0) { + return; + } + + setSuggestionValues((prevState) => ({...prevState, ...nextState})); + setHighlightedCommandIndex(0); + }, + [setHighlightedCommandIndex, resetSuggestions], + ); + + useEffect(() => { + if (!isComposerFocused) { + return; + } + + calculateCommandSuggestion(value, selection.start, selection.end); + }, [value, selection, calculateCommandSuggestion, isComposerFocused]); + + const setShouldBlockSuggestionCalc = useCallback( + (shouldBlockSuggestionCalc: boolean) => { + shouldBlockCalc.current = shouldBlockSuggestionCalc; + }, + [shouldBlockCalc], + ); + + const getSuggestions = useCallback(() => suggestionValues.suggestedCommands, [suggestionValues]); + + const getIsSuggestionsMenuVisible = useCallback(() => isCommandSuggestionsMenuVisible, [isCommandSuggestionsMenuVisible]); + + useImperativeHandle( + ref, + () => ({ + resetSuggestions, + triggerHotkeyActions, + setShouldBlockSuggestionCalc, + updateShouldShowSuggestionMenuToFalse, + getSuggestions, + getIsSuggestionsMenuVisible, + }), + [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible], + ); + + if (!isCommandSuggestionsMenuVisible) { + return null; + } + + return ( + + ); +} + +SuggestionCommand.displayName = 'SuggestionCommand'; + +export default forwardRef(SuggestionCommand); +export type {Command}; diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx index 8b7171340b63..1b30ee2d1033 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/Suggestions.tsx @@ -7,6 +7,7 @@ import type {TextSelection} from '@components/Composer/types'; import {DragAndDropContext} from '@components/DragAndDrop/Provider'; import usePrevious from '@hooks/usePrevious'; import type {SuggestionsRef} from './ReportActionCompose'; +import SuggestionCommand from './SuggestionCommand'; import SuggestionEmoji from './SuggestionEmoji'; import SuggestionMention from './SuggestionMention'; @@ -67,6 +68,7 @@ function Suggestions( ) { const suggestionEmojiRef = useRef(null); const suggestionMentionRef = useRef(null); + const suggestionCommandRef = useRef(null); const {isDraggingOver} = useContext(DragAndDropContext); const prevIsDraggingOver = usePrevious(isDraggingOver); @@ -85,6 +87,13 @@ function Suggestions( } } + if (suggestionCommandRef.current?.getSuggestions) { + const commandSuggestions = suggestionCommandRef.current.getSuggestions(); + if (commandSuggestions.length > 0) { + return commandSuggestions; + } + } + return []; }, []); @@ -94,6 +103,7 @@ function Suggestions( const resetSuggestions = useCallback(() => { suggestionEmojiRef.current?.resetSuggestions(); suggestionMentionRef.current?.resetSuggestions(); + suggestionCommandRef.current?.resetSuggestions(); }, []); /** @@ -102,28 +112,34 @@ function Suggestions( const triggerHotkeyActions = useCallback((e: KeyboardEvent) => { const emojiHandler = suggestionEmojiRef.current?.triggerHotkeyActions(e); const mentionHandler = suggestionMentionRef.current?.triggerHotkeyActions(e); - return emojiHandler ?? mentionHandler; + const commandHandler = suggestionCommandRef.current?.triggerHotkeyActions(e); + + return emojiHandler ?? mentionHandler ?? commandHandler; }, []); const onSelectionChange = useCallback((e: NativeSyntheticEvent) => { const emojiHandler = suggestionEmojiRef.current?.onSelectionChange?.(e); + const commandHandler = suggestionCommandRef.current?.onSelectionChange?.(e); suggestionMentionRef.current?.onSelectionChange?.(e); - return emojiHandler; + return emojiHandler ?? commandHandler; }, []); const updateShouldShowSuggestionMenuToFalse = useCallback(() => { suggestionEmojiRef.current?.updateShouldShowSuggestionMenuToFalse(); suggestionMentionRef.current?.updateShouldShowSuggestionMenuToFalse(); + suggestionCommandRef.current?.updateShouldShowSuggestionMenuToFalse(); }, []); const setShouldBlockSuggestionCalc = useCallback((shouldBlock: boolean) => { suggestionEmojiRef.current?.setShouldBlockSuggestionCalc(shouldBlock); suggestionMentionRef.current?.setShouldBlockSuggestionCalc(shouldBlock); + suggestionCommandRef.current?.setShouldBlockSuggestionCalc(shouldBlock); }, []); const getIsSuggestionsMenuVisible = useCallback((): boolean => { const isEmojiVisible = suggestionEmojiRef.current?.getIsSuggestionsMenuVisible() ?? false; const isSuggestionVisible = suggestionMentionRef.current?.getIsSuggestionsMenuVisible() ?? false; - return isEmojiVisible || isSuggestionVisible; + const isCommandVisible = suggestionCommandRef.current?.getIsSuggestionsMenuVisible() ?? false; + return isEmojiVisible || isSuggestionVisible || isCommandVisible; }, []); useImperativeHandle( @@ -172,6 +188,12 @@ function Suggestions( // eslint-disable-next-line react/jsx-props-no-spreading {...baseProps} /> + ); } From a66088fe13a2e3eb4d16d87fe7caef37a7fe888a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 7 Feb 2025 10:15:07 -0800 Subject: [PATCH 02/29] Initial implementation of the list of composer commands --- src/CONST.ts | 31 +++++++++++++++++++++++++++++++ src/languages/en.ts | 6 ++++++ src/languages/es.ts | 6 ++++++ 3 files changed, 43 insertions(+) diff --git a/src/CONST.ts b/src/CONST.ts index b8af68ddb934..f9d4434ecea0 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -7,6 +7,7 @@ import invertBy from 'lodash/invertBy'; import Config from 'react-native-config'; import * as KeyCommand from 'react-native-key-command'; import type {ValueOf} from 'type-fest'; +import type {TranslationPaths} from './languages/types'; import type {Video} from './libs/actions/Report'; import type {MileageRate} from './libs/DistanceRequestUtils'; import BankAccount from './libs/models/BankAccount'; @@ -307,6 +308,35 @@ type OnboardingMessage = { type?: string; }; +type ComposerCommand = { + command: string; + icon: string; + descriptionKey: TranslationPaths; +}; + +const COMPOSER_COMMANDS: ComposerCommand[] = [ + { + command: '/summarize', + icon: 'TODO', + descriptionKey: 'composer.commands.summarize', + }, + { + command: '/export', + icon: 'TODO', + descriptionKey: 'composer.commands.export', + }, + { + command: '/request', + icon: 'TODO', + descriptionKey: 'composer.commands.request', + }, + { + command: '/split', + icon: 'TODO', + descriptionKey: 'composer.commands.split', + }, +]; + const EMAIL_WITH_OPTIONAL_DOMAIN = /(?=((?=[\w'#%+-]+(?:\.[\w'#%+-]+)*@?)[\w.'#%+-]{1,64}(?:@(?:(?=[a-z\d]+(?:-+[a-z\d]+)*\.)(?:[a-z\d-]{1,63}\.)+[a-z]{2,63}))?(?= |_|\b))(?.*))\S{3,254}(?=\k$)/; @@ -6627,6 +6657,7 @@ const CONST = { }, SKIPPABLE_COLLECTION_MEMBER_IDS: [String(DEFAULT_NUMBER_ID), '-1', 'undefined', 'null', 'NaN'] as string[], SETUP_SPECIALIST_LOGIN: 'Setup Specialist', + COMPOSER_COMMANDS, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/languages/en.ts b/src/languages/en.ts index 6551eb5e8e9c..e27a1a7f6bd2 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -554,6 +554,12 @@ const translations = { problemGettingImageYouPasted: 'There was a problem getting the image you pasted', commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum comment length is ${formattedMaxLength} characters.`, taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum task title length is ${formattedMaxLength} characters.`, + commands: { + summarize: 'Summarize messages', + request: 'TODO', + export: 'Export expenses report', + split: 'TODO', + }, }, baseUpdateAppModal: { updateApp: 'Update app', diff --git a/src/languages/es.ts b/src/languages/es.ts index f2db2c5b49b8..ea682d5cbd62 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -545,6 +545,12 @@ const translations = { problemGettingImageYouPasted: 'Ha ocurrido un problema al obtener la imagen que has pegado', commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `El comentario debe tener máximo ${formattedMaxLength} caracteres.`, taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `La longitud máxima del título de una tarea es de ${formattedMaxLength} caracteres.`, + commands: { + summarize: 'Resumir mensajes', + request: 'TODO', + export: 'Exportar informe de gastos', + split: 'TODO', + }, }, baseUpdateAppModal: { updateApp: 'Actualizar app', From 67f0833c20ccda3992a2aab781b34790c3b2a464 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 11:14:03 -0800 Subject: [PATCH 03/29] Add translations --- src/languages/en.ts | 4 ++-- src/languages/es.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index e27a1a7f6bd2..d37279d1c89a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -556,9 +556,9 @@ const translations = { taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum task title length is ${formattedMaxLength} characters.`, commands: { summarize: 'Summarize messages', - request: 'TODO', export: 'Export expenses report', - split: 'TODO', + create: 'Create expense', + insight: 'Learn about your spending', }, }, baseUpdateAppModal: { diff --git a/src/languages/es.ts b/src/languages/es.ts index ea682d5cbd62..c2de525791c1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -547,9 +547,9 @@ const translations = { taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `La longitud máxima del título de una tarea es de ${formattedMaxLength} caracteres.`, commands: { summarize: 'Resumir mensajes', - request: 'TODO', export: 'Exportar informe de gastos', - split: 'TODO', + create: 'Crear gasto', + insight: 'Conocer tus gastos', }, }, baseUpdateAppModal: { From b61309aca27046de27732c5074155a909055a28b Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 11:14:15 -0800 Subject: [PATCH 04/29] Fix available commands --- src/CONST.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index f9d4434ecea0..45758552c306 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6,7 +6,9 @@ import type {Dictionary} from 'lodash'; import invertBy from 'lodash/invertBy'; import Config from 'react-native-config'; import * as KeyCommand from 'react-native-key-command'; +import type {SvgProps} from 'react-native-svg'; import type {ValueOf} from 'type-fest'; +import * as Expensicons from './components/Icon/Expensicons'; import type {TranslationPaths} from './languages/types'; import type {Video} from './libs/actions/Report'; import type {MileageRate} from './libs/DistanceRequestUtils'; @@ -310,30 +312,30 @@ type OnboardingMessage = { type ComposerCommand = { command: string; - icon: string; + icon: React.FC; descriptionKey: TranslationPaths; }; const COMPOSER_COMMANDS: ComposerCommand[] = [ { command: '/summarize', - icon: 'TODO', + icon: Expensicons.Document, descriptionKey: 'composer.commands.summarize', }, { command: '/export', - icon: 'TODO', + icon: Expensicons.Export, descriptionKey: 'composer.commands.export', }, { - command: '/request', - icon: 'TODO', - descriptionKey: 'composer.commands.request', + command: '/create', + icon: Expensicons.ReceiptPlus, + descriptionKey: 'composer.commands.create', }, { - command: '/split', - icon: 'TODO', - descriptionKey: 'composer.commands.split', + command: '/insight', + icon: Expensicons.Mute, + descriptionKey: 'composer.commands.insight', }, ]; @@ -6682,6 +6684,7 @@ export type { CancellationType, OnboardingInvite, OnboardingAccounting, + ComposerCommand, }; export default CONST; From f27ce61cb10ff1462e8e3ab6b1a6976bc8eb8d84 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 11:14:28 -0800 Subject: [PATCH 05/29] suggestCommands util --- src/libs/CommandUtils.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/libs/CommandUtils.ts diff --git a/src/libs/CommandUtils.ts b/src/libs/CommandUtils.ts new file mode 100644 index 000000000000..d3e07cfb5934 --- /dev/null +++ b/src/libs/CommandUtils.ts @@ -0,0 +1,21 @@ +import type {ComposerCommand} from '@src/CONST'; +import CONST from '@src/CONST'; + +function suggestCommands(text: string, limit: number = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS): ComposerCommand[] { + const suggestions: ComposerCommand[] = []; + + for (const composedCommand of CONST.COMPOSER_COMMANDS) { + if (suggestions.length === limit) { + break; + } + + if (composedCommand.command.startsWith(text)) { + suggestions.push(composedCommand); + } + } + + return suggestions; +} + +// eslint-disable-next-line import/prefer-default-export +export {suggestCommands}; From b82ab3482f4f1cfda01aba1af86c530342dba4c4 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 11:14:47 -0800 Subject: [PATCH 06/29] Fix one type --- .../home/report/ReportActionCompose/ReportActionCompose.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index f91a156b4afc..26d37bee8886 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -45,6 +45,7 @@ import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActio import {addAttachment as addAttachmentReportActions, setIsComposerFullSize} from '@userActions/Report'; import Timing from '@userActions/Timing'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; +import type {ComposerCommand} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -54,7 +55,6 @@ import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef, ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions'; import SendButton from './SendButton'; -import {Command} from './SuggestionCommand'; type SuggestionsRef = { resetSuggestions: () => void; @@ -62,7 +62,7 @@ type SuggestionsRef = { triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; - getSuggestions: () => Mention[] | Emoji[] | Command[]; + getSuggestions: () => Mention[] | Emoji[] | ComposerCommand[]; getIsSuggestionsMenuVisible: () => boolean; }; From c680a82807b16a07913eae60fd009ee08fc7f684 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 11:20:33 -0800 Subject: [PATCH 07/29] Finish styling suggestions --- src/components/CommandSuggestions.tsx | 40 +++++++++++++------ .../ReportActionCompose/SuggestionCommand.tsx | 17 +++----- src/styles/index.ts | 18 +++++++++ 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx index 8d091f7dd150..39ef9bf33aa2 100644 --- a/src/components/CommandSuggestions.tsx +++ b/src/components/CommandSuggestions.tsx @@ -1,11 +1,15 @@ import type {ReactElement} from 'react'; import React, {useCallback} from 'react'; import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {Command} from '@pages/home/report/ReportActionCompose/SuggestionCommand'; +import getStyledTextArray from '@libs/GetStyledTextArray'; +import type {ComposerCommand} from '@src/CONST'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; import type {MeasureParentContainerAndCursorCallback} from './AutoCompleteSuggestions/types'; +import Icon from './Icon'; import Text from './Text'; type CommandSuggestionsProps = { @@ -13,7 +17,10 @@ type CommandSuggestionsProps = { highlightedCommandIndex?: number; /** Array of suggested command */ - commands: Command[]; + commands: ComposerCommand[]; + + /** Current composer value */ + value: string; /** Fired when the user selects an command */ onSelect: (index: number) => void; @@ -28,26 +35,33 @@ type CommandSuggestionsProps = { /** * Create unique keys for each command item */ -const keyExtractor = (item: Command, index: number): string => `${item.name}+${index}}`; +const keyExtractor = (item: ComposerCommand, index: number): string => `${item.command}+${index}`; -function CommandSuggestions({commands, onSelect, highlightedCommandIndex = 0, measureParentContainerAndReportCursor = () => {}, resetSuggestions}: CommandSuggestionsProps) { +function CommandSuggestions({commands, onSelect, value, highlightedCommandIndex = 0, measureParentContainerAndReportCursor = () => {}, resetSuggestions}: CommandSuggestionsProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const {translate} = useLocalize(); + /** * Render an command suggestion menu item component. */ const renderSuggestionMenuItem = useCallback( - (item: Command): ReactElement => { - const styledTextArray = [{text: 'test', isColored: false}]; + (item: ComposerCommand): ReactElement => { + const styledTextArray = getStyledTextArray(item.command, value); return ( - - test + + - : {styledTextArray.map(({text, isColored}) => ( ))} - : + {translate(item.descriptionKey)} ); }, - [styles.autoCompleteSuggestionContainer, styles.emojiSuggestionsEmoji, styles.emojiSuggestionsText, StyleUtils], + [value, styles.autoCompleteCommandSuggestionContainer, styles.emojiCommandSuggestionsText, styles.commandSuggestions, theme.iconSuccessFill, translate, StyleUtils], ); return ( diff --git a/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx index d7fc67dc6d66..bef2a2b25c47 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx @@ -2,20 +2,16 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import CommandSuggestions from '@components/CommandSuggestions'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; -import useLocalize from '@hooks/useLocalize'; +import {suggestCommands} from '@libs/CommandUtils'; import * as SuggestionsUtils from '@libs/SuggestionUtils'; +import type {ComposerCommand} from '@src/CONST'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {SuggestionsRef} from './ReportActionCompose'; import type {SuggestionProps} from './Suggestions'; -type Command = { - code: string; - description: string; -}; - type SuggestionsValue = { - suggestedCommands: Command[]; + suggestedCommands: ComposerCommand[]; shouldShowSuggestionMenu: boolean; }; @@ -51,12 +47,11 @@ function SuggestionCommand( /** * Replace the code of command and update selection - * @param {Number} selectedCommand */ const insertSelectedCommand = useCallback( (commandIndex: number) => { const commandObj = commandIndex !== -1 ? suggestionValues.suggestedCommands.at(commandIndex) : undefined; - const commandCode = commandObj?.code; + const commandCode = commandObj?.command; const commentAfterColonWithCommandNameRemoved = value.slice(selection.end); updateComment(`${commandCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithCommandNameRemoved)}`, true); @@ -135,7 +130,7 @@ function SuggestionCommand( suggestedCommands: [], shouldShowSuggestionMenu: false, }; - const newSuggestedCommands = []; + const newSuggestedCommands = suggestCommands(newValue); if (newSuggestedCommands?.length && isCurrentlyShowingCommandSuggestion) { nextState.suggestedCommands = newSuggestedCommands; @@ -194,6 +189,7 @@ function SuggestionCommand( borderRadius: 8, }, + autoCompleteCommandSuggestionContainer: { + flexDirection: 'row', + alignItems: 'center', + ...spacing.mh3, + }, + commandSuggestions: { + color: theme.textSupporting, + fontSize: variables.fontSizeMedium, + textAlign: 'center', + }, + emojiCommandSuggestionsText: { + fontSize: variables.fontSizeMedium, + flex: 1, + ...wordBreak.breakWord, + ...spacing.pl3, + ...spacing.pr2, + }, + mentionSuggestionsAvatarContainer: { width: 24, height: 24, From 7b21cd87cf00c4b2c7ac4e02cf53191fa53a7f5d Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 11:44:28 -0800 Subject: [PATCH 08/29] Update size of the icon --- src/components/CommandSuggestions.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx index 39ef9bf33aa2..65b61b81a238 100644 --- a/src/components/CommandSuggestions.tsx +++ b/src/components/CommandSuggestions.tsx @@ -16,7 +16,7 @@ type CommandSuggestionsProps = { /** The index of the highlighted command */ highlightedCommandIndex?: number; - /** Array of suggested command */ + /** Array of suggested commands */ commands: ComposerCommand[]; /** Current composer value */ @@ -55,8 +55,7 @@ function CommandSuggestions({commands, onSelect, value, highlightedCommandIndex Date: Fri, 7 Feb 2025 12:17:04 -0800 Subject: [PATCH 09/29] Initial API integration with commands in composer --- src/CONST.ts | 11 ++- .../API/parameters/AddActionCommandParams.ts | 11 +++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/ReportUtils.ts | 1 + src/libs/actions/Report.ts | 87 ++++++++++++++++++- src/libs/actions/RequestConflictUtils.ts | 3 +- src/pages/home/report/ReportFooter.tsx | 8 ++ 8 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 src/libs/API/parameters/AddActionCommandParams.ts diff --git a/src/CONST.ts b/src/CONST.ts index f9d4434ecea0..4b7e433cac52 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -308,8 +308,11 @@ type OnboardingMessage = { type?: string; }; +type ComposerCommandAction = 'summarize' | 'export' | 'request' | 'split'; + type ComposerCommand = { - command: string; + command: `/${ComposerCommandAction}`; + action: ComposerCommandAction; icon: string; descriptionKey: TranslationPaths; }; @@ -317,21 +320,25 @@ type ComposerCommand = { const COMPOSER_COMMANDS: ComposerCommand[] = [ { command: '/summarize', + action: 'summarize', icon: 'TODO', descriptionKey: 'composer.commands.summarize', }, { command: '/export', + action: 'export', icon: 'TODO', descriptionKey: 'composer.commands.export', }, { command: '/request', + action: 'request', icon: 'TODO', descriptionKey: 'composer.commands.request', }, { command: '/split', + action: 'split', icon: 'TODO', descriptionKey: 'composer.commands.split', }, @@ -6682,6 +6689,8 @@ export type { CancellationType, OnboardingInvite, OnboardingAccounting, + ComposerCommandAction, + ComposerCommand, }; export default CONST; diff --git a/src/libs/API/parameters/AddActionCommandParams.ts b/src/libs/API/parameters/AddActionCommandParams.ts new file mode 100644 index 000000000000..469a11e25f34 --- /dev/null +++ b/src/libs/API/parameters/AddActionCommandParams.ts @@ -0,0 +1,11 @@ +import type {ComposerCommandAction} from '@src/CONST'; + +type AddActionCommandParams = { + reportID: string; + reportActionID: string; + answerReportActionID: string; + reportComment: string; + actionType: ComposerCommandAction; +}; + +export default AddActionCommandParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index e9ca2a39b0a2..98a9976152c9 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -94,6 +94,7 @@ export type {default as DisableTwoFactorAuthParams} from './DisableTwoFactorAuth export type {default as VerifyIdentityForBankAccountParams} from './VerifyIdentityForBankAccountParams'; export type {default as AnswerQuestionsForWalletParams} from './AnswerQuestionsForWalletParams'; export type {default as AddCommentOrAttachementParams} from './AddCommentOrAttachementParams'; +export type {default as AddActionCommandParams} from './AddActionCommandParams'; export type {default as ReadNewestActionParams} from './ReadNewestActionParams'; export type {default as MarkAsUnreadParams} from './MarkAsUnreadParams'; export type {default as TogglePinnedChatParams} from './TogglePinnedChatParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index ec9da6201db5..05f2c32212f2 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -101,6 +101,7 @@ const WRITE_COMMANDS = { ENABLE_TWO_FACTOR_AUTH: 'EnableTwoFactorAuth', DISABLE_TWO_FACTOR_AUTH: 'DisableTwoFactorAuth', ADD_COMMENT: 'AddComment', + ADD_ACTION_COMMENT: 'AddActionComment', ADD_ATTACHMENT: 'AddAttachment', ADD_TEXT_AND_ATTACHMENT: 'AddTextAndAttachment', CONNECT_BANK_ACCOUNT_WITH_PLAID: 'ConnectBankAccountWithPlaid', @@ -544,6 +545,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_TWO_FACTOR_AUTH]: null; [WRITE_COMMANDS.DISABLE_TWO_FACTOR_AUTH]: Parameters.DisableTwoFactorAuthParams; [WRITE_COMMANDS.ADD_COMMENT]: Parameters.AddCommentOrAttachementParams; + [WRITE_COMMANDS.ADD_ACTION_COMMENT]: Parameters.AddActionCommandParams; [WRITE_COMMANDS.ADD_ATTACHMENT]: Parameters.AddCommentOrAttachementParams; [WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]: Parameters.AddCommentOrAttachementParams; [WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_WITH_PLAID]: Parameters.ConnectBankAccountParams; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ea11eced2b30..54bad902e786 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -242,6 +242,7 @@ type OptimisticAddCommentReportAction = Pick< | 'childLastVisibleActionCreated' | 'childOldestFourAccountIDs' | 'delegateAccountID' + | 'whisperedToAccountIDs' > & {isOptimisticAction: boolean}; type OptimisticReportAction = { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index bc051f1472bb..3d03ffcee3b6 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -11,6 +11,7 @@ import type {FileObject} from '@components/AttachmentModal'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import * as API from '@libs/API'; import type { + AddActionCommandParams, AddCommentOrAttachementParams, AddEmojiReactionParams, AddWorkspaceRoomParams, @@ -130,7 +131,7 @@ import {getNavatticURL} from '@libs/TourUtils'; import {generateAccountID} from '@libs/UserUtils'; import Visibility from '@libs/Visibility'; import CONFIG from '@src/CONFIG'; -import type {OnboardingAccounting, OnboardingCompanySize} from '@src/CONST'; +import type {ComposerCommand, OnboardingAccounting, OnboardingCompanySize} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -722,6 +723,89 @@ function addComment(reportID: string, text: string) { addActions(reportID, text); } +/** Add an action comment to a report */ +function addActionComment(reportID: string, text: string, command: ComposerCommand) { + let requestCommentText = ''; + + const requestComment = buildOptimisticAddCommentReportAction(text, undefined, undefined, undefined, undefined, reportID); + const requestCommentAction: OptimisticAddCommentReportAction = requestComment.reportAction; + requestCommentAction.whisperedToAccountIDs = [currentUserAccountID]; + + requestCommentText = requestComment.commentText.slice(command.command.length); + + const answerComment = buildOptimisticAddCommentReportAction(text, undefined, undefined, undefined, undefined, reportID); + const answerCommentAction: OptimisticAddCommentReportAction = answerComment.reportAction; + answerCommentAction.whisperedToAccountIDs = [currentUserAccountID]; + + const optimisticReportActions: OnyxCollection = {}; + optimisticReportActions[requestCommentAction.reportActionID] = requestCommentAction; + optimisticReportActions[answerCommentAction.reportActionID] = answerCommentAction; + + const parameters: AddActionCommandParams = { + reportID, + reportActionID: requestCommentAction.reportActionID, + answerReportActionID: answerCommentAction.reportActionID, + reportComment: requestCommentText, + actionType: command.action, + }; + + const optimisticData: OnyxUpdate[] = [ + // { + // onyxMethod: Onyx.METHOD.MERGE, + // key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + // value: optimisticReport, + // }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: optimisticReportActions as ReportActions, + }, + ]; + + const successReportActions: OnyxCollection> = {}; + + Object.entries(optimisticReportActions).forEach(([actionKey]) => { + successReportActions[actionKey] = {pendingAction: null, isOptimisticAction: null}; + }); + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: successReportActions, + }, + ]; + + const failureReportActions: Record = {}; + + Object.entries(optimisticReportActions).forEach(([actionKey, _action]) => { + failureReportActions[actionKey] = { + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + ...(_action as OptimisticAddCommentReportAction), + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), + }; + }); + + const failureData: OnyxUpdate[] = [ + // { + // onyxMethod: Onyx.METHOD.MERGE, + // key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + // value: failureReport, + // }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: failureReportActions as ReportActions, + }, + ]; + + API.write(WRITE_COMMANDS.ADD_ACTION_COMMENT, parameters, { + optimisticData, + successData, + failureData, + }); +} + function reportActionsExist(reportID: string): boolean { return allReportActions?.[reportID] !== undefined; } @@ -4672,6 +4756,7 @@ export type {Video}; export { addAttachment, addComment, + addActionComment, addPolicyReport, broadcastUserIsLeavingRoom, broadcastUserIsTyping, diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 7e1092016e28..87576ba9f6f7 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -8,10 +8,11 @@ import type {ConflictActionData} from '@src/types/onyx/Request'; type RequestMatcher = (request: OnyxRequest) => boolean; -const addNewMessage = new Set([WRITE_COMMANDS.ADD_COMMENT, WRITE_COMMANDS.ADD_ATTACHMENT, WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]); +const addNewMessage = new Set([WRITE_COMMANDS.ADD_COMMENT, WRITE_COMMANDS.ADD_ACTION_COMMENT, WRITE_COMMANDS.ADD_ATTACHMENT, WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]); const commentsToBeDeleted = new Set([ WRITE_COMMANDS.ADD_COMMENT, + WRITE_COMMANDS.ADD_ACTION_COMMENT, WRITE_COMMANDS.ADD_ATTACHMENT, WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT, WRITE_COMMANDS.UPDATE_COMMENT, diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index e97e4c611922..c3b8dd862425 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -167,6 +167,14 @@ function ReportFooter({ if (isTaskCreated) { return; } + + for (const command of CONST.COMPOSER_COMMANDS) { + if (text === command.action || text.startsWith(`${command.action} `)) { + Report.addActionComment(report.reportID, text, command); + return; + } + } + Report.addComment(report.reportID, text); }, // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps From 7c236c93f76e9f4efcd0edf5a9338772f4842813 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 12:19:37 -0800 Subject: [PATCH 10/29] Add optional arguments! --- src/CONST.ts | 9 +++++++++ src/languages/en.ts | 1 + src/languages/es.ts | 1 + .../ReportActionCompose/SuggestionCommand.tsx | 15 ++++++++++----- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 45758552c306..ada649e7ad4a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -311,9 +311,17 @@ type OnboardingMessage = { }; type ComposerCommand = { + /** Name of the command */ command: string; + + /** Icon to be displayed next to the command name */ icon: React.FC; + + /** Translation key for the description */ descriptionKey: TranslationPaths; + + /** An example argument that will be included with the command */ + exampleArgument?: TranslationPaths; }; const COMPOSER_COMMANDS: ComposerCommand[] = [ @@ -321,6 +329,7 @@ const COMPOSER_COMMANDS: ComposerCommand[] = [ command: '/summarize', icon: Expensicons.Document, descriptionKey: 'composer.commands.summarize', + exampleArgument: 'composer.commands.summarizeExampleArgument', }, { command: '/export', diff --git a/src/languages/en.ts b/src/languages/en.ts index d37279d1c89a..d7c136777ad5 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -556,6 +556,7 @@ const translations = { taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum task title length is ${formattedMaxLength} characters.`, commands: { summarize: 'Summarize messages', + summarizeExampleArgument: 'All messages from 3 days ago', export: 'Export expenses report', create: 'Create expense', insight: 'Learn about your spending', diff --git a/src/languages/es.ts b/src/languages/es.ts index c2de525791c1..7a7fbc7b5240 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -547,6 +547,7 @@ const translations = { taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `La longitud máxima del título de una tarea es de ${formattedMaxLength} caracteres.`, commands: { summarize: 'Resumir mensajes', + summarizeExampleArgument: 'Todos los mensajes de hace 3 días', export: 'Exportar informe de gastos', create: 'Crear gasto', insight: 'Conocer tus gastos', diff --git a/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx index bef2a2b25c47..eec822b042a0 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx @@ -2,6 +2,7 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import CommandSuggestions from '@components/CommandSuggestions'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import useLocalize from '@hooks/useLocalize'; import {suggestCommands} from '@libs/CommandUtils'; import * as SuggestionsUtils from '@libs/SuggestionUtils'; import type {ComposerCommand} from '@src/CONST'; @@ -31,6 +32,7 @@ function SuggestionCommand( ) { const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); const suggestionValuesRef = useRef(suggestionValues); + const {translate} = useLocalize(); // eslint-disable-next-line react-compiler/react-compiler suggestionValuesRef.current = suggestionValues; @@ -52,9 +54,11 @@ function SuggestionCommand( (commandIndex: number) => { const commandObj = commandIndex !== -1 ? suggestionValues.suggestedCommands.at(commandIndex) : undefined; const commandCode = commandObj?.command; - const commentAfterColonWithCommandNameRemoved = value.slice(selection.end); + const trailingCommentText = value.slice(selection.end); + const commandExampleArgument = commandObj?.exampleArgument ? translate(commandObj.exampleArgument) : ''; + const restOfComment = trailingCommentText ? SuggestionsUtils.trimLeadingSpace(trailingCommentText) : commandExampleArgument; - updateComment(`${commandCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithCommandNameRemoved)}`, true); + updateComment(`${commandCode} ${restOfComment}`, true); // In some Android phones keyboard, the text to search for the command is not cleared // will be added after the user starts typing again on the keyboard. This package is @@ -63,11 +67,11 @@ function SuggestionCommand( setSelection({ start: (commandCode?.length ?? 0) + CONST.SPACE_LENGTH, - end: (commandCode?.length ?? 0) + CONST.SPACE_LENGTH, + end: (commandCode?.length ?? 0) + CONST.SPACE_LENGTH + restOfComment.length, }); setSuggestionValues((prevState) => ({...prevState, suggestedCommands: []})); }, - [resetKeyboardInput, selection.end, setSelection, suggestionValues.suggestedCommands, updateComment, value], + [resetKeyboardInput, selection.end, setSelection, suggestionValues.suggestedCommands, translate, updateComment, value], ); /** @@ -125,12 +129,13 @@ function SuggestionCommand( return; } const isCurrentlyShowingCommandSuggestion = newValue.startsWith('/'); + const leftString = newValue.substring(0, selectionEnd); const nextState: SuggestionsValue = { suggestedCommands: [], shouldShowSuggestionMenu: false, }; - const newSuggestedCommands = suggestCommands(newValue); + const newSuggestedCommands = suggestCommands(leftString); if (newSuggestedCommands?.length && isCurrentlyShowingCommandSuggestion) { nextState.suggestedCommands = newSuggestedCommands; From 12d2e619c84cc63af5ddd91ac6bd52a6d8e89497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 7 Feb 2025 13:18:41 -0800 Subject: [PATCH 11/29] Minor fixes --- src/libs/ReportUtils.ts | 1 - src/libs/actions/Report.ts | 10 +++++++--- src/pages/home/report/ReportFooter.tsx | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 54bad902e786..ea11eced2b30 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -242,7 +242,6 @@ type OptimisticAddCommentReportAction = Pick< | 'childLastVisibleActionCreated' | 'childOldestFourAccountIDs' | 'delegateAccountID' - | 'whisperedToAccountIDs' > & {isOptimisticAction: boolean}; type OptimisticReportAction = { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 3d03ffcee3b6..a50ae1366ead 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -729,13 +729,17 @@ function addActionComment(reportID: string, text: string, command: ComposerComma const requestComment = buildOptimisticAddCommentReportAction(text, undefined, undefined, undefined, undefined, reportID); const requestCommentAction: OptimisticAddCommentReportAction = requestComment.reportAction; - requestCommentAction.whisperedToAccountIDs = [currentUserAccountID]; + if (requestCommentAction.originalMessage) { + requestCommentAction.originalMessage.whisperedTo = [currentUserAccountID]; + } requestCommentText = requestComment.commentText.slice(command.command.length); - const answerComment = buildOptimisticAddCommentReportAction(text, undefined, undefined, undefined, undefined, reportID); + const answerComment = buildOptimisticAddCommentReportAction('Analyzing...', undefined, CONST.ACCOUNT_ID.CONCIERGE, undefined, undefined, reportID); const answerCommentAction: OptimisticAddCommentReportAction = answerComment.reportAction; - answerCommentAction.whisperedToAccountIDs = [currentUserAccountID]; + if (answerCommentAction.originalMessage) { + answerCommentAction.originalMessage.whisperedTo = [currentUserAccountID]; + } const optimisticReportActions: OnyxCollection = {}; optimisticReportActions[requestCommentAction.reportActionID] = requestCommentAction; diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index c3b8dd862425..06e8aa4a0d3c 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -169,7 +169,7 @@ function ReportFooter({ } for (const command of CONST.COMPOSER_COMMANDS) { - if (text === command.action || text.startsWith(`${command.action} `)) { + if (text === command.command || text.startsWith(`${command.command} `)) { Report.addActionComment(report.reportID, text, command); return; } From ea28ae9f1711cd7cfa5e855ac88289b75477157c Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 14:06:06 -0800 Subject: [PATCH 12/29] Handle disabled commands --- src/CONST.ts | 9 ++++++++- src/components/CommandSuggestions.tsx | 20 +++++++++++++++++-- src/languages/en.ts | 1 + src/languages/es.ts | 1 + .../ReportActionCompose/SuggestionCommand.tsx | 5 +++-- src/pages/home/report/ReportFooter.tsx | 6 +++--- 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index d37059e61ab2..b8b619d90b12 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -327,6 +327,9 @@ type ComposerCommand = { /** An example argument that will be included with the command */ exampleArgument?: TranslationPaths; + + /** If the command is disabled */ + disabled: boolean; }; const COMPOSER_COMMANDS: ComposerCommand[] = [ @@ -336,24 +339,28 @@ const COMPOSER_COMMANDS: ComposerCommand[] = [ icon: Expensicons.Document, descriptionKey: 'composer.commands.summarize', exampleArgument: 'composer.commands.summarizeExampleArgument', + disabled: false, }, { command: '/export', action: 'export', icon: Expensicons.Export, descriptionKey: 'composer.commands.export', + disabled: true, }, { command: '/create', action: 'create', icon: Expensicons.ReceiptPlus, descriptionKey: 'composer.commands.create', + disabled: true, }, { command: '/insight', action: 'insight', icon: Expensicons.Mute, descriptionKey: 'composer.commands.insight', + disabled: true, }, ]; @@ -1774,7 +1781,7 @@ const CONST = { MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER: 5, HERE_TEXT: '@here', SUGGESTION_BOX_MAX_SAFE_DISTANCE: 10, - BIG_SCREEN_SUGGESTION_WIDTH: 300, + BIG_SCREEN_SUGGESTION_WIDTH: 400, }, COMPOSER_MAX_HEIGHT: 125, CHAT_FOOTER_SECONDARY_ROW_HEIGHT: 15, diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx index 65b61b81a238..0ed1d317d0cc 100644 --- a/src/components/CommandSuggestions.tsx +++ b/src/components/CommandSuggestions.tsx @@ -9,6 +9,7 @@ import getStyledTextArray from '@libs/GetStyledTextArray'; import type {ComposerCommand} from '@src/CONST'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; import type {MeasureParentContainerAndCursorCallback} from './AutoCompleteSuggestions/types'; +import Badge from './Badge'; import Icon from './Icon'; import Text from './Text'; @@ -51,7 +52,7 @@ function CommandSuggestions({commands, onSelect, value, highlightedCommandIndex const styledTextArray = getStyledTextArray(item.command, value); return ( - + {translate(item.descriptionKey)} + ); }, - [value, styles.autoCompleteCommandSuggestionContainer, styles.emojiCommandSuggestionsText, styles.commandSuggestions, theme.iconSuccessFill, translate, StyleUtils], + [ + value, + styles.autoCompleteCommandSuggestionContainer, + styles.opacitySemiTransparent, + styles.emojiCommandSuggestionsText, + styles.commandSuggestions, + styles.activeItemBadge, + styles.borderColorFocus, + theme.iconSuccessFill, + translate, + StyleUtils, + ], ); return ( diff --git a/src/languages/en.ts b/src/languages/en.ts index d7c136777ad5..05a78bd8a432 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -236,6 +236,7 @@ const translations = { in: 'In', optional: 'Optional', new: 'New', + coming: 'Coming', search: 'Search', reports: 'Reports', find: 'Find', diff --git a/src/languages/es.ts b/src/languages/es.ts index 7a7fbc7b5240..e9c86672358b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -226,6 +226,7 @@ const translations = { in: 'En', optional: 'Opcional', new: 'Nuevo', + coming: 'Próximamente', center: 'Centrar', search: 'Buscar', reports: 'Informes', diff --git a/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx index eec822b042a0..ca17fa4892a7 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx @@ -96,10 +96,11 @@ function SuggestionCommand( const triggerHotkeyActions = useCallback( (e: KeyboardEvent) => { const suggestionsExist = suggestionValues.suggestedCommands.length > 0; + const isCommandDisabled = suggestionValues.suggestedCommands.at(highlightedCommandIndex)?.disabled ?? true; if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { e.preventDefault(); - if (suggestionValues.suggestedCommands.length > 0) { + if (suggestionValues.suggestedCommands.length > 0 && !isCommandDisabled) { insertSelectedCommand(highlightedCommandIndex); } return true; @@ -115,7 +116,7 @@ function SuggestionCommand( return true; } }, - [highlightedCommandIndex, insertSelectedCommand, resetSuggestions, suggestionValues.suggestedCommands.length], + [highlightedCommandIndex, insertSelectedCommand, resetSuggestions, suggestionValues.suggestedCommands], ); /** diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 06e8aa4a0d3c..6d47876b5ede 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -169,9 +169,9 @@ function ReportFooter({ } for (const command of CONST.COMPOSER_COMMANDS) { - if (text === command.command || text.startsWith(`${command.command} `)) { - Report.addActionComment(report.reportID, text, command); - return; + const isCommandInText = text === command.command || text.startsWith(`${command.command} `); + if (isCommandInText && !command.disabled) { + return Report.addActionComment(report.reportID, text, command); } } From 28f0ad083e6838512f88d54c4b1e192541a31363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 7 Feb 2025 14:47:11 -0800 Subject: [PATCH 13/29] Fixes optimistically composer command messages order --- src/libs/actions/Report.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index a50ae1366ead..db61836d8e29 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -732,6 +732,8 @@ function addActionComment(reportID: string, text: string, command: ComposerComma if (requestCommentAction.originalMessage) { requestCommentAction.originalMessage.whisperedTo = [currentUserAccountID]; } + const nowDate = Date.now(); + requestCommentAction.created = DateUtils.getDBTimeWithSkew(nowDate); requestCommentText = requestComment.commentText.slice(command.command.length); @@ -740,6 +742,7 @@ function addActionComment(reportID: string, text: string, command: ComposerComma if (answerCommentAction.originalMessage) { answerCommentAction.originalMessage.whisperedTo = [currentUserAccountID]; } + answerCommentAction.created = DateUtils.getDBTimeWithSkew(nowDate + 100); const optimisticReportActions: OnyxCollection = {}; optimisticReportActions[requestCommentAction.reportActionID] = requestCommentAction; @@ -754,11 +757,6 @@ function addActionComment(reportID: string, text: string, command: ComposerComma }; const optimisticData: OnyxUpdate[] = [ - // { - // onyxMethod: Onyx.METHOD.MERGE, - // key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - // value: optimisticReport, - // }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, @@ -782,20 +780,15 @@ function addActionComment(reportID: string, text: string, command: ComposerComma const failureReportActions: Record = {}; - Object.entries(optimisticReportActions).forEach(([actionKey, _action]) => { + Object.entries(optimisticReportActions).forEach(([actionKey, actionData]) => { failureReportActions[actionKey] = { // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - ...(_action as OptimisticAddCommentReportAction), + ...(actionData as OptimisticAddCommentReportAction), errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), }; }); const failureData: OnyxUpdate[] = [ - // { - // onyxMethod: Onyx.METHOD.MERGE, - // key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - // value: failureReport, - // }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, From 5b2e646d1405327047a45678e97544f522fa8943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 7 Feb 2025 14:51:17 -0800 Subject: [PATCH 14/29] Fix API parameters to send the whole text --- src/libs/actions/Report.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index db61836d8e29..9c906d46443b 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -725,18 +725,15 @@ function addComment(reportID: string, text: string) { /** Add an action comment to a report */ function addActionComment(reportID: string, text: string, command: ComposerCommand) { - let requestCommentText = ''; + const nowDate = Date.now(); const requestComment = buildOptimisticAddCommentReportAction(text, undefined, undefined, undefined, undefined, reportID); const requestCommentAction: OptimisticAddCommentReportAction = requestComment.reportAction; if (requestCommentAction.originalMessage) { requestCommentAction.originalMessage.whisperedTo = [currentUserAccountID]; } - const nowDate = Date.now(); requestCommentAction.created = DateUtils.getDBTimeWithSkew(nowDate); - requestCommentText = requestComment.commentText.slice(command.command.length); - const answerComment = buildOptimisticAddCommentReportAction('Analyzing...', undefined, CONST.ACCOUNT_ID.CONCIERGE, undefined, undefined, reportID); const answerCommentAction: OptimisticAddCommentReportAction = answerComment.reportAction; if (answerCommentAction.originalMessage) { @@ -752,7 +749,7 @@ function addActionComment(reportID: string, text: string, command: ComposerComma reportID, reportActionID: requestCommentAction.reportActionID, answerReportActionID: answerCommentAction.reportActionID, - reportComment: requestCommentText, + reportComment: requestComment.commentText, actionType: command.action, }; From ad1c2ac5b7525e8fd8fe725ab175a8e3978a6614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 7 Feb 2025 15:02:13 -0800 Subject: [PATCH 15/29] Do not group whisper report actions --- src/libs/ReportActionsUtils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 3dfd62d1c00a..b93dc4d43991 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -569,6 +569,11 @@ function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | return false; } + // Do not group if the current action is a whisper one + if (isWhisperAction(currentAction)) { + return false; + } + // Do not group if one of previous / current action is report preview and another one is not report preview if ((isReportPreviewAction(previousAction) && !isReportPreviewAction(currentAction)) || (isReportPreviewAction(currentAction) && !isReportPreviewAction(previousAction))) { return false; From d602635f45b43ef36d8c1b49f339632a8560df53 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 15:58:40 -0800 Subject: [PATCH 16/29] Add background for /summarize command --- ...-live-markdown+0.1.230+001+hackathon.patch | 745 ++++++++++++++++++ ...pensify-common+2.0.115+001+hackathon.patch | 569 +++++++++++++ src/CONST.ts | 2 +- .../BaseHTMLEngineProvider.tsx | 1 + .../HTMLRenderers/CommandRenderer.tsx | 31 + .../HTMLEngineProvider/HTMLRenderers/index.ts | 2 + src/hooks/useMarkdownStyle.ts | 4 + src/libs/SelectionScraper/index.ts | 2 +- 8 files changed, 1354 insertions(+), 2 deletions(-) create mode 100644 patches/@expensify+react-native-live-markdown+0.1.230+001+hackathon.patch create mode 100644 patches/expensify-common+2.0.115+001+hackathon.patch create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx diff --git a/patches/@expensify+react-native-live-markdown+0.1.230+001+hackathon.patch b/patches/@expensify+react-native-live-markdown+0.1.230+001+hackathon.patch new file mode 100644 index 000000000000..c87e175c385e --- /dev/null +++ b/patches/@expensify+react-native-live-markdown+0.1.230+001+hackathon.patch @@ -0,0 +1,745 @@ +diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js b/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js +index f492c22..3c3712e 100644 +--- a/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js ++++ b/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js +@@ -1,249 +1,272 @@ + 'worklet'; + +-import { Platform } from 'react-native'; +-import { ExpensiMark } from 'expensify-common'; +-import { unescapeText } from 'expensify-common/dist/utils'; +-import { decode } from 'html-entities'; +-import { groupRanges, sortRanges, splitRangesOnEmojis } from './rangeUtils'; ++import {ExpensiMark} from 'expensify-common'; ++import {unescapeText} from 'expensify-common/dist/utils'; ++import {decode} from 'html-entities'; ++import {Platform} from 'react-native'; ++import {groupRanges, sortRanges, splitRangesOnEmojis} from './rangeUtils'; ++ + function isWeb() { +- return Platform.OS === 'web'; ++ return Platform.OS === 'web'; + } + function isJest() { +- return !!global.process.env.JEST_WORKER_ID; ++ return !!global.process.env.JEST_WORKER_ID; + } + + // eslint-disable-next-line no-underscore-dangle + if (__DEV__ && !isWeb() && !isJest() && decode.__workletHash === undefined) { +- throw new Error("[react-native-live-markdown] `parseExpensiMark` requires `html-entities` package to be workletized. Please add `'worklet';` directive at the top of `node_modules/html-entities/lib/index.js` using patch-package."); ++ throw new Error( ++ "[react-native-live-markdown] `parseExpensiMark` requires `html-entities` package to be workletized. Please add `'worklet';` directive at the top of `node_modules/html-entities/lib/index.js` using patch-package.", ++ ); + } + const MAX_PARSABLE_LENGTH = 4000; + function parseMarkdownToHTML(markdown) { +- const parser = new ExpensiMark(); +- const html = parser.replace(markdown, { +- shouldKeepRawInput: true +- }); +- return html; ++ const parser = new ExpensiMark(); ++ const html = parser.replace(markdown, { ++ shouldKeepRawInput: true, ++ }); ++ ++ return html; + } + function parseHTMLToTokens(html) { +- const tokens = []; +- let left = 0; +- // eslint-disable-next-line no-constant-condition +- while (true) { +- const open = html.indexOf('<', left); +- if (open === -1) { +- if (left < html.length) { +- tokens.push(['TEXT', html.substring(left)]); +- } +- break; +- } +- if (open !== left) { +- tokens.push(['TEXT', html.substring(left, open)]); +- } +- const close = html.indexOf('>', open); +- if (close === -1) { +- throw new Error('[react-native-live-markdown] Error in function parseHTMLToTokens: Invalid HTML: no matching ">"'); ++ const tokens = []; ++ let left = 0; ++ // eslint-disable-next-line no-constant-condition ++ while (true) { ++ const open = html.indexOf('<', left); ++ if (open === -1) { ++ if (left < html.length) { ++ tokens.push(['TEXT', html.substring(left)]); ++ } ++ break; ++ } ++ if (open !== left) { ++ tokens.push(['TEXT', html.substring(left, open)]); ++ } ++ const close = html.indexOf('>', open); ++ if (close === -1) { ++ throw new Error('[react-native-live-markdown] Error in function parseHTMLToTokens: Invalid HTML: no matching ">"'); ++ } ++ tokens.push(['HTML', html.substring(open, close + 1)]); ++ left = close + 1; + } +- tokens.push(['HTML', html.substring(open, close + 1)]); +- left = close + 1; +- } +- return tokens; ++ return tokens; + } + function parseTokensToTree(tokens) { +- const stack = [{ +- tag: '<>', +- children: [] +- }]; +- tokens.forEach(([type, payload]) => { +- if (type === 'TEXT') { +- const text = unescapeText(payload); +- const top = stack[stack.length - 1]; +- top.children.push(text); +- } else if (type === 'HTML') { +- if (payload.startsWith('')) { +- // self-closing tag +- const top = stack[stack.length - 1]; +- top.children.push({ +- tag: payload, +- children: [] +- }); +- } else { +- // opening tag +- stack.push({ +- tag: payload, +- children: [] +- }); +- } +- } else { +- throw new Error(`[react-native-live-markdown] Error in function parseTokensToTree: Unknown token type: ${type}. Expected 'TEXT' or 'HTML'. Please ensure tokens only contain these types.`); ++ const stack = [ ++ { ++ tag: '<>', ++ children: [], ++ }, ++ ]; ++ tokens.forEach(([type, payload]) => { ++ if (type === 'TEXT') { ++ const text = unescapeText(payload); ++ const top = stack[stack.length - 1]; ++ top.children.push(text); ++ } else if (type === 'HTML') { ++ if (payload.startsWith('')) { ++ // self-closing tag ++ const top = stack[stack.length - 1]; ++ top.children.push({ ++ tag: payload, ++ children: [], ++ }); ++ } else { ++ // opening tag ++ stack.push({ ++ tag: payload, ++ children: [], ++ }); ++ } ++ } else { ++ throw new Error( ++ `[react-native-live-markdown] Error in function parseTokensToTree: Unknown token type: ${type}. Expected 'TEXT' or 'HTML'. Please ensure tokens only contain these types.`, ++ ); ++ } ++ }); ++ if (stack.length !== 1) { ++ const unclosedTags = ++ stack.length > 0 ++ ? stack ++ .slice(1) ++ .map((item) => item.tag) ++ .join(', ') ++ : ''; ++ throw new Error( ++ `[react-native-live-markdown] Invalid HTML structure: the following tags are not properly closed: ${unclosedTags}. Ensure each opening tag has a corresponding closing tag.`, ++ ); + } +- }); +- if (stack.length !== 1) { +- const unclosedTags = stack.length > 0 ? stack.slice(1).map(item => item.tag).join(', ') : ''; +- throw new Error(`[react-native-live-markdown] Invalid HTML structure: the following tags are not properly closed: ${unclosedTags}. Ensure each opening tag has a corresponding closing tag.`); +- } +- return stack[0]; ++ return stack[0]; + } + function parseTreeToTextAndRanges(tree) { +- let text = ''; +- function processChildren(node) { +- if (typeof node === 'string') { +- text += node; +- } else { +- node.children.forEach(dfs); +- } +- } +- function appendSyntax(syntax) { +- addChildrenWithStyle(syntax, 'syntax'); +- } +- function addChildrenWithStyle(node, type) { +- const start = text.length; +- processChildren(node); +- const end = text.length; +- ranges.push({ +- type, +- start, +- length: end - start +- }); +- } +- const ranges = []; +- function dfs(node) { +- if (typeof node === 'string') { +- text += node; +- } else { +- // eslint-disable-next-line no-lonely-if +- if (node.tag === '<>') { +- processChildren(node); +- } else if (node.tag === '') { +- appendSyntax('*'); +- addChildrenWithStyle(node, 'bold'); +- appendSyntax('*'); +- } else if (node.tag === '') { +- appendSyntax('_'); +- addChildrenWithStyle(node, 'italic'); +- appendSyntax('_'); +- } else if (node.tag === '') { +- appendSyntax('~'); +- addChildrenWithStyle(node, 'strikethrough'); +- appendSyntax('~'); +- } else if (node.tag === '') { +- addChildrenWithStyle(node, 'emoji'); +- } else if (node.tag === '') { +- appendSyntax('`'); +- addChildrenWithStyle(node, 'code'); +- appendSyntax('`'); +- } else if (node.tag === '') { +- addChildrenWithStyle(node, 'mention-here'); +- } else if (node.tag === '') { +- addChildrenWithStyle(node, 'mention-user'); +- } else if (node.tag === '') { +- addChildrenWithStyle(node, 'mention-short'); +- } else if (node.tag === '') { +- addChildrenWithStyle(node, 'mention-report'); +- } else if (node.tag === '
') { +- appendSyntax('>'); +- addChildrenWithStyle(node, 'blockquote'); +- // compensate for "> " at the beginning +- if (ranges.length > 0) { +- const curr = ranges[ranges.length - 1]; +- curr.start -= 1; +- curr.length += 1; +- } +- } else if (node.tag === '

') { +- appendSyntax('# '); +- addChildrenWithStyle(node, 'h1'); +- } else if (node.tag === '
') { +- text += '\n'; +- } else if (node.tag.startsWith(' processChildren(child)); +- appendSyntax(']'); ++ } ++ const ranges = []; ++ function dfs(node) { ++ if (typeof node === 'string') { ++ text += node; ++ } else { ++ // eslint-disable-next-line no-lonely-if ++ if (node.tag === '<>') { ++ processChildren(node); ++ } else if (node.tag === '') { ++ appendSyntax('*'); ++ addChildrenWithStyle(node, 'bold'); ++ appendSyntax('*'); ++ } else if (node.tag === '') { ++ appendSyntax('_'); ++ addChildrenWithStyle(node, 'italic'); ++ appendSyntax('_'); ++ } else if (node.tag === '') { ++ appendSyntax('~'); ++ addChildrenWithStyle(node, 'strikethrough'); ++ appendSyntax('~'); ++ } else if (node.tag === '') { ++ addChildrenWithStyle(node, 'emoji'); ++ } else if (node.tag === '') { ++ appendSyntax('`'); ++ addChildrenWithStyle(node, 'code'); ++ appendSyntax('`'); ++ } else if (node.tag === '') { ++ addChildrenWithStyle(node, 'mention-here'); ++ } else if (node.tag === '') { ++ addChildrenWithStyle(node, 'mention-user'); ++ } else if (node.tag === '') { ++ addChildrenWithStyle(node, 'mention-short'); ++ } else if (node.tag === '') { ++ addChildrenWithStyle(node, 'command'); ++ } else if (node.tag === '') { ++ addChildrenWithStyle(node, 'mention-report'); ++ } else if (node.tag === '
') { ++ appendSyntax('>'); ++ addChildrenWithStyle(node, 'blockquote'); ++ // compensate for "> " at the beginning ++ if (ranges.length > 0) { ++ const curr = ranges[ranges.length - 1]; ++ curr.start -= 1; ++ curr.length += 1; ++ } ++ } else if (node.tag === '

') { ++ appendSyntax('# '); ++ addChildrenWithStyle(node, 'h1'); ++ } else if (node.tag === '
') { ++ text += '\n'; ++ } else if (node.tag.startsWith(' processChildren(child)); ++ appendSyntax(']'); ++ } ++ appendSyntax('('); ++ addChildrenWithStyle(linkString, 'link'); ++ appendSyntax(')'); ++ } else { ++ throw new Error(`[react-native-live-markdown] Error in function parseTreeToTextAndRanges: Unknown tag '${node.tag}'. This tag is not supported in this function's logic.`); ++ } + } +- appendSyntax('('); +- addChildrenWithStyle(linkString, 'link'); +- appendSyntax(')'); +- } else { +- throw new Error(`[react-native-live-markdown] Error in function parseTreeToTextAndRanges: Unknown tag '${node.tag}'. This tag is not supported in this function's logic.`); +- } + } +- } +- dfs(tree); +- return [text, ranges]; ++ dfs(tree); ++ return [text, ranges]; + } + const isAndroid = Platform.OS === 'android'; + function parseExpensiMark(markdown) { +- if (markdown.length > MAX_PARSABLE_LENGTH) { +- return []; +- } +- const html = parseMarkdownToHTML(markdown); +- const tokens = parseHTMLToTokens(html); +- const tree = parseTokensToTree(tokens); +- const [text, ranges] = parseTreeToTextAndRanges(tree); +- if (text !== markdown) { +- console.error(`[react-native-live-markdown] Parsing error: the processed text does not match the original Markdown input. This may be caused by incorrect parsing functions or invalid input Markdown.\nProcessed input: '${JSON.stringify(text)}'\nOriginal input: '${JSON.stringify(markdown)}'`); +- return []; +- } +- let markdownRanges = sortRanges(ranges); +- if (isAndroid) { +- // Blocks applying italic and strikethrough styles to emojis on Android +- // TODO: Remove this condition when splitting emojis inside the inline code block will be fixed on the web +- markdownRanges = splitRangesOnEmojis(markdownRanges, 'italic'); +- markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); +- } +- const groupedRanges = groupRanges(markdownRanges); +- return groupedRanges; ++ if (markdown.length > MAX_PARSABLE_LENGTH) { ++ return []; ++ } ++ const html = parseMarkdownToHTML(markdown); ++ const tokens = parseHTMLToTokens(html); ++ const tree = parseTokensToTree(tokens); ++ const [text, ranges] = parseTreeToTextAndRanges(tree); ++ if (text !== markdown) { ++ console.error( ++ `[react-native-live-markdown] Parsing error: the processed text does not match the original Markdown input. This may be caused by incorrect parsing functions or invalid input Markdown.\nProcessed input: '${JSON.stringify( ++ text, ++ )}'\nOriginal input: '${JSON.stringify(markdown)}'`, ++ ); ++ return []; ++ } ++ let markdownRanges = sortRanges(ranges); ++ if (isAndroid) { ++ // Blocks applying italic and strikethrough styles to emojis on Android ++ // TODO: Remove this condition when splitting emojis inside the inline code block will be fixed on the web ++ markdownRanges = splitRangesOnEmojis(markdownRanges, 'italic'); ++ markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); ++ } ++ const groupedRanges = groupRanges(markdownRanges); ++ return groupedRanges; + } + export default parseExpensiMark; + //# sourceMappingURL=parseExpensiMark.js.map +diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js +index 1ab9c63..4f92154 100644 +--- a/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js ++++ b/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js +@@ -39,6 +39,10 @@ function makeDefaultMarkdownStyle() { + color: 'green', + backgroundColor: 'lime' + }, ++ command: { ++ color: 'green', ++ backgroundColor: 'lime' ++ }, + mentionUser: { + color: 'blue', + backgroundColor: 'cyan' +diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js +index ad119d2..f738be8 100644 +--- a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js ++++ b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js +@@ -1,113 +1,118 @@ +-import { addInlineImagePreview } from '../inputElements/inlineImage'; ++import {addInlineImagePreview} from '../inputElements/inlineImage'; ++ + function addStyleToBlock(targetElement, type, markdownStyle, isMultiline = true) { +- const node = targetElement; +- switch (type) { +- case 'line': +- Object.assign(node.style, { +- margin: '0', +- padding: '0' +- }); +- break; +- case 'syntax': +- Object.assign(node.style, markdownStyle.syntax); +- break; +- case 'bold': +- node.style.fontWeight = 'bold'; +- break; +- case 'italic': +- node.style.fontStyle = 'italic'; +- break; +- case 'strikethrough': +- node.style.textDecoration = 'line-through'; +- break; +- case 'emoji': +- Object.assign(node.style, { +- ...markdownStyle.emoji, +- verticalAlign: 'middle', +- fontStyle: 'normal', +- // remove italic +- textDecoration: 'none', +- // remove strikethrough +- display: 'inline-block' +- }); +- break; +- case 'mention-here': +- Object.assign(node.style, markdownStyle.mentionHere); +- break; +- case 'mention-user': +- Object.assign(node.style, markdownStyle.mentionUser); +- break; +- case 'mention-report': +- Object.assign(node.style, markdownStyle.mentionReport); +- break; +- case 'link': +- Object.assign(node.style, { +- ...markdownStyle.link, +- textDecoration: 'underline' +- }); +- break; +- case 'code': +- Object.assign(node.style, markdownStyle.code); +- break; +- case 'pre': +- Object.assign(node.style, markdownStyle.pre); +- break; +- case 'blockquote': +- Object.assign(node.style, { +- ...markdownStyle.blockquote, +- borderLeftStyle: 'solid', +- display: 'inline-block', +- maxWidth: '100%', +- boxSizing: 'border-box', +- overflowWrap: 'anywhere' +- }); +- break; +- case 'h1': +- Object.assign(node.style, { +- ...markdownStyle.h1, +- fontWeight: 'bold' +- }); +- break; +- case 'block': +- Object.assign(node.style, { +- display: 'block', +- margin: '0', +- padding: '0', +- position: 'relative' +- }); +- break; +- case 'text': +- if (!isMultiline) { +- var _targetElement$parent, _targetElement$parent2; +- Object.assign(node.style, { +- backgroundColor: (_targetElement$parent = targetElement.parentElement) === null || _targetElement$parent === void 0 ? void 0 : _targetElement$parent.style.backgroundColor +- }); +- Object.assign(((_targetElement$parent2 = targetElement.parentElement) === null || _targetElement$parent2 === void 0 ? void 0 : _targetElement$parent2.style) ?? {}, { +- backgroundColor: 'transparent' +- }); +- } +- break; +- default: +- break; +- } ++ const node = targetElement; ++ switch (type) { ++ case 'line': ++ Object.assign(node.style, { ++ margin: '0', ++ padding: '0', ++ }); ++ break; ++ case 'syntax': ++ Object.assign(node.style, markdownStyle.syntax); ++ break; ++ case 'bold': ++ node.style.fontWeight = 'bold'; ++ break; ++ case 'italic': ++ node.style.fontStyle = 'italic'; ++ break; ++ case 'strikethrough': ++ node.style.textDecoration = 'line-through'; ++ break; ++ case 'emoji': ++ Object.assign(node.style, { ++ ...markdownStyle.emoji, ++ verticalAlign: 'middle', ++ fontStyle: 'normal', ++ // remove italic ++ textDecoration: 'none', ++ // remove strikethrough ++ display: 'inline-block', ++ }); ++ break; ++ case 'mention-here': ++ Object.assign(node.style, markdownStyle.mentionHere); ++ break; ++ case 'command': ++ Object.assign(node.style, markdownStyle.command); ++ break; ++ case 'mention-user': ++ Object.assign(node.style, markdownStyle.mentionUser); ++ break; ++ case 'mention-report': ++ Object.assign(node.style, markdownStyle.mentionReport); ++ break; ++ case 'link': ++ Object.assign(node.style, { ++ ...markdownStyle.link, ++ textDecoration: 'underline', ++ }); ++ break; ++ case 'code': ++ Object.assign(node.style, markdownStyle.code); ++ break; ++ case 'pre': ++ Object.assign(node.style, markdownStyle.pre); ++ break; ++ case 'blockquote': ++ Object.assign(node.style, { ++ ...markdownStyle.blockquote, ++ borderLeftStyle: 'solid', ++ display: 'inline-block', ++ maxWidth: '100%', ++ boxSizing: 'border-box', ++ overflowWrap: 'anywhere', ++ }); ++ break; ++ case 'h1': ++ Object.assign(node.style, { ++ ...markdownStyle.h1, ++ fontWeight: 'bold', ++ }); ++ break; ++ case 'block': ++ Object.assign(node.style, { ++ display: 'block', ++ margin: '0', ++ padding: '0', ++ position: 'relative', ++ }); ++ break; ++ case 'text': ++ if (!isMultiline) { ++ var _targetElement$parent, _targetElement$parent2; ++ Object.assign(node.style, { ++ backgroundColor: ++ (_targetElement$parent = targetElement.parentElement) === null || _targetElement$parent === void 0 ? void 0 : _targetElement$parent.style.backgroundColor, ++ }); ++ Object.assign(((_targetElement$parent2 = targetElement.parentElement) === null || _targetElement$parent2 === void 0 ? void 0 : _targetElement$parent2.style) ?? {}, { ++ backgroundColor: 'transparent', ++ }); ++ } ++ break; ++ default: ++ break; ++ } + } + const BLOCK_MARKDOWN_TYPES = ['inline-image']; + const FULL_LINE_MARKDOWN_TYPES = ['blockquote']; + function isBlockMarkdownType(type) { +- return BLOCK_MARKDOWN_TYPES.includes(type); ++ return BLOCK_MARKDOWN_TYPES.includes(type); + } + function getFirstBlockMarkdownRange(ranges) { +- const blockMarkdownRange = ranges.find(r => isBlockMarkdownType(r.type) || FULL_LINE_MARKDOWN_TYPES.includes(r.type)); +- return FULL_LINE_MARKDOWN_TYPES.includes((blockMarkdownRange === null || blockMarkdownRange === void 0 ? void 0 : blockMarkdownRange.type) || '') ? undefined : blockMarkdownRange; ++ const blockMarkdownRange = ranges.find((r) => isBlockMarkdownType(r.type) || FULL_LINE_MARKDOWN_TYPES.includes(r.type)); ++ return FULL_LINE_MARKDOWN_TYPES.includes((blockMarkdownRange === null || blockMarkdownRange === void 0 ? void 0 : blockMarkdownRange.type) || '') ? undefined : blockMarkdownRange; + } + function extendBlockStructure(currentInput, targetNode, currentRange, ranges, text, markdownStyle, inlineImagesProps) { +- switch (currentRange.type) { +- case 'inline-image': +- return addInlineImagePreview(currentInput, targetNode, text, ranges, markdownStyle, inlineImagesProps); +- default: +- break; +- } +- return targetNode; ++ switch (currentRange.type) { ++ case 'inline-image': ++ return addInlineImagePreview(currentInput, targetNode, text, ranges, markdownStyle, inlineImagesProps); ++ default: ++ break; ++ } ++ return targetNode; + } +-export { addStyleToBlock, extendBlockStructure, isBlockMarkdownType, getFirstBlockMarkdownRange }; ++export {addStyleToBlock, extendBlockStructure, isBlockMarkdownType, getFirstBlockMarkdownRange}; + //# sourceMappingURL=blockUtils.js.map +diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js +index dcbe8fa..c096d8f 100644 +--- a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js ++++ b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js +@@ -76,7 +76,7 @@ function addTextToElement(node, text, isMultiline = true) { + span.appendChild(document.createTextNode(line)); + appendNode(span, node, 'text', line.length); + const parentType = (_span$parentElement = span.parentElement) === null || _span$parentElement === void 0 ? void 0 : _span$parentElement.dataset.type; +- if (!isMultiline && parentType && ['pre', 'code', 'mention-here', 'mention-user', 'mention-report'].includes(parentType)) { ++ if (!isMultiline && parentType && ['pre', 'code', 'mention-here', 'mention-user', 'mention-report', 'command'].includes(parentType)) { + // this is a fix to background colors being shifted downwards in a singleline input + addStyleToBlock(span, 'text', {}, false); + } diff --git a/patches/expensify-common+2.0.115+001+hackathon.patch b/patches/expensify-common+2.0.115+001+hackathon.patch new file mode 100644 index 000000000000..909b433b40c2 --- /dev/null +++ b/patches/expensify-common+2.0.115+001+hackathon.patch @@ -0,0 +1,569 @@ +diff --git a/node_modules/expensify-common/dist/ExpensiMark.js b/node_modules/expensify-common/dist/ExpensiMark.js +index 5c8cd83..7df90cd 100644 +--- a/node_modules/expensify-common/dist/ExpensiMark.js ++++ b/node_modules/expensify-common/dist/ExpensiMark.js +@@ -1,41 +1,61 @@ +-"use strict"; ++'use strict'; + 'worklet'; +-var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { +- if (k2 === undefined) k2 = k; +- var desc = Object.getOwnPropertyDescriptor(m, k); +- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { +- desc = { enumerable: true, get: function() { return m[k]; } }; +- } +- Object.defineProperty(o, k2, desc); +-}) : (function(o, m, k, k2) { +- if (k2 === undefined) k2 = k; +- o[k2] = m[k]; +-})); +-var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { +- Object.defineProperty(o, "default", { enumerable: true, value: v }); +-}) : function(o, v) { +- o["default"] = v; +-}); +-var __importStar = (this && this.__importStar) || function (mod) { +- if (mod && mod.__esModule) return mod; +- var result = {}; +- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); +- __setModuleDefault(result, mod); +- return result; +-}; +-var __importDefault = (this && this.__importDefault) || function (mod) { +- return (mod && mod.__esModule) ? mod : { "default": mod }; +-}; +-Object.defineProperty(exports, "__esModule", { value: true }); +-const str_1 = __importDefault(require("./str")); +-const Constants = __importStar(require("./CONST")); +-const UrlPatterns = __importStar(require("./Url")); +-const Logger_1 = __importDefault(require("./Logger")); +-const Utils = __importStar(require("./utils")); ++var __createBinding = ++ (this && this.__createBinding) || ++ (Object.create ++ ? function (o, m, k, k2) { ++ if (k2 === undefined) k2 = k; ++ var desc = Object.getOwnPropertyDescriptor(m, k); ++ if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) { ++ desc = { ++ enumerable: true, ++ get: function () { ++ return m[k]; ++ }, ++ }; ++ } ++ Object.defineProperty(o, k2, desc); ++ } ++ : function (o, m, k, k2) { ++ if (k2 === undefined) k2 = k; ++ o[k2] = m[k]; ++ }); ++var __setModuleDefault = ++ (this && this.__setModuleDefault) || ++ (Object.create ++ ? function (o, v) { ++ Object.defineProperty(o, 'default', {enumerable: true, value: v}); ++ } ++ : function (o, v) { ++ o['default'] = v; ++ }); ++var __importStar = ++ (this && this.__importStar) || ++ function (mod) { ++ if (mod && mod.__esModule) return mod; ++ var result = {}; ++ if (mod != null) for (var k in mod) if (k !== 'default' && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); ++ __setModuleDefault(result, mod); ++ return result; ++ }; ++var __importDefault = ++ (this && this.__importDefault) || ++ function (mod) { ++ return mod && mod.__esModule ? mod : {default: mod}; ++ }; ++Object.defineProperty(exports, '__esModule', {value: true}); ++const str_1 = __importDefault(require('./str')); ++const Constants = __importStar(require('./CONST')); ++const UrlPatterns = __importStar(require('./Url')); ++const Logger_1 = __importDefault(require('./Logger')); ++const Utils = __importStar(require('./utils')); + const EXTRAS_DEFAULT = {}; + const MARKDOWN_LINK_REGEX = new RegExp(`\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi'); + const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi'); +-const MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join('|')}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi'); ++const MARKDOWN_VIDEO_REGEX = new RegExp( ++ `\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join('|')}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, ++ 'gi', ++); + const SLACK_SPAN_NEW_LINE_TAG = ''; + class ExpensiMark { + /** +@@ -49,7 +69,7 @@ class ExpensiMark { + this.getAttributeCache = (extras) => { + var _a, _b; + if (!extras) { +- return { attrCachingFn: undefined, attrCache: undefined }; ++ return {attrCachingFn: undefined, attrCache: undefined}; + } + return { + attrCachingFn: (_a = extras.mediaAttributeCachingFn) !== null && _a !== void 0 ? _a : extras.cacheVideoAttributes, +@@ -116,7 +136,9 @@ class ExpensiMark { + rawInputReplacement: (extras, _match, videoName, videoSource) => { + const attrCache = this.getAttributeCache(extras).attrCache; + const extraAttrs = attrCache && attrCache[videoSource]; +- return ``; ++ return ``; + }, + }, + /** +@@ -196,7 +218,9 @@ class ExpensiMark { + rawInputReplacement: (extras, _match, imgAlt, imgSource) => { + const attrCache = this.getAttributeCache(extras).attrCache; + const extraAttrs = attrCache && attrCache[imgSource]; +- return `${this.escapeAttributeContent(imgAlt)}`; ++ return `${this.escapeAttributeContent(imgAlt)}`; + }, + }, + /** +@@ -237,6 +261,13 @@ class ExpensiMark { + return `${g1}${g2}${g3}`; + }, + }, ++ { ++ name: 'commandSummarize', ++ regex: /^(\/summarize)(?=\s|$)/gm, ++ replacement: (_extras, match) => { ++ return `${match}`; ++ }, ++ }, + /** + * A room mention is a string that starts with the '#' symbol and is followed by a valid room name. + * +@@ -259,7 +290,10 @@ class ExpensiMark { + */ + { + name: 'userMentions', +- regex: new RegExp(`(@here|[a-zA-Z0-9.!$%&+=?^\`{|}-]?)(@${Constants.CONST.REG_EXP.EMAIL_PART}|@${Constants.CONST.REG_EXP.PHONE_PART})(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, 'gim'), ++ regex: new RegExp( ++ `(@here|[a-zA-Z0-9.!$%&+=?^\`{|}-]?)(@${Constants.CONST.REG_EXP.EMAIL_PART}|@${Constants.CONST.REG_EXP.PHONE_PART})(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, ++ 'gim', ++ ), + replacement: (_extras, match, g1, g2) => { + const phoneNumberRegex = new RegExp(`^${Constants.CONST.REG_EXP.PHONE_PART}$`); + const mention = g2.slice(1); +@@ -322,11 +356,11 @@ class ExpensiMark { + return replacedText; + }, + replacement: (_extras, g1) => { +- const { replacedText } = this.replaceQuoteText(g1, false); ++ const {replacedText} = this.replaceQuoteText(g1, false); + return `
${replacedText || ' '}
`; + }, + rawInputReplacement: (_extras, g1) => { +- const { replacedText, shouldAddSpace } = this.replaceQuoteText(g1, true); ++ const {replacedText, shouldAddSpace} = this.replaceQuoteText(g1, true); + return `
${shouldAddSpace ? ' ' : ''}${replacedText}
`; + }, + }, +@@ -425,13 +459,14 @@ class ExpensiMark { + name: 'newline', + // Replaces open and closing

tags with a single
+ // Slack uses special tag for empty lines instead of
tag +- pre: (inputString) => inputString +- .replace('

', '
') +- .replace('

', '
') +- .replace(/()/g, '$1
') +- .replace('
', '') +- .replace(SLACK_SPAN_NEW_LINE_TAG + SLACK_SPAN_NEW_LINE_TAG, '


') +- .replace(SLACK_SPAN_NEW_LINE_TAG, '

'), ++ pre: (inputString) => ++ inputString ++ .replace('

', '
') ++ .replace('

', '
') ++ .replace(/()/g, '$1
') ++ .replace('
', '') ++ .replace(SLACK_SPAN_NEW_LINE_TAG + SLACK_SPAN_NEW_LINE_TAG, '


') ++ .replace(SLACK_SPAN_NEW_LINE_TAG, '

'), + // Include the immediately followed newline as `
\n` should be equal to one \n. + regex: /<])*>\n?/gi, + replacement: '\n', +@@ -501,15 +536,15 @@ class ExpensiMark { + }); + resultString = resultString + .map((text) => { +- let modifiedText = text; +- let depth; +- do { +- depth = (modifiedText.match(/
/gi) || []).length; +- modifiedText = modifiedText.replace(/
/gi, ''); +- modifiedText = modifiedText.replace(/<\/blockquote>/gi, ''); +- } while (/
/i.test(modifiedText)); +- return `${'>'.repeat(depth)} ${modifiedText}`; +- }) ++ let modifiedText = text; ++ let depth; ++ do { ++ depth = (modifiedText.match(/
/gi) || []).length; ++ modifiedText = modifiedText.replace(/
/gi, ''); ++ modifiedText = modifiedText.replace(/<\/blockquote>/gi, ''); ++ } while (/
/i.test(modifiedText)); ++ return `${'>'.repeat(depth)} ${modifiedText}`; ++ }) + .join('\n'); + // We want to keep
tag here and let method replaceBlockElementWithNewLine to handle the line break later + return `
${resultString}
`; +@@ -601,7 +636,7 @@ class ExpensiMark { + replacement: (extras, _match, g1, _offset, _string) => { + const reportToNameMap = extras.reportIDToName; + if (!reportToNameMap || !reportToNameMap[g1]) { +- ExpensiMark.Log.alert('[ExpensiMark] Missing report name', { reportID: g1 }); ++ ExpensiMark.Log.alert('[ExpensiMark] Missing report name', {reportID: g1}); + return '#Hidden'; + } + return reportToNameMap[g1]; +@@ -615,7 +650,7 @@ class ExpensiMark { + if (g1) { + const accountToNameMap = extras.accountIDToName; + if (!accountToNameMap || !accountToNameMap[g1]) { +- ExpensiMark.Log.alert('[ExpensiMark] Missing account name', { accountID: g1 }); ++ ExpensiMark.Log.alert('[ExpensiMark] Missing account name', {accountID: g1}); + return '@Hidden'; + } + return `@${str_1.default.removeSMSDomain((_b = (_a = extras.accountIDToName) === null || _a === void 0 ? void 0 : _a[g1]) !== null && _b !== void 0 ? _b : '')}`; +@@ -680,7 +715,7 @@ class ExpensiMark { + replacement: (extras, _match, g1, _offset, _string) => { + const reportToNameMap = extras.reportIDToName; + if (!reportToNameMap || !reportToNameMap[g1]) { +- ExpensiMark.Log.alert('[ExpensiMark] Missing report name', { reportID: g1 }); ++ ExpensiMark.Log.alert('[ExpensiMark] Missing report name', {reportID: g1}); + return '#Hidden'; + } + return reportToNameMap[g1]; +@@ -693,7 +728,7 @@ class ExpensiMark { + var _a, _b; + const accountToNameMap = extras.accountIDToName; + if (!accountToNameMap || !accountToNameMap[g1]) { +- ExpensiMark.Log.alert('[ExpensiMark] Missing account name', { accountID: g1 }); ++ ExpensiMark.Log.alert('[ExpensiMark] Missing account name', {accountID: g1}); + return '@Hidden'; + } + return `@${str_1.default.removeSMSDomain((_b = (_a = extras.accountIDToName) === null || _a === void 0 ? void 0 : _a[g1]) !== null && _b !== void 0 ? _b : '')}`; +@@ -704,6 +739,31 @@ class ExpensiMark { + regex: /(<([^>]+)>)/gi, + replacement: '', + }, ++ /** ++ * Apply the hereMention first because the string @here is still a valid mention for the userMention regex. ++ * This ensures that the hereMention is always considered first, even if it is followed by a valid ++ * userMention. ++ * ++ * Also, apply the mention rule after email/link to prevent mention appears in an email/link. ++ */ ++ { ++ name: 'hereMentions', ++ regex: /([a-zA-Z0-9.!$%&+/=?^`{|}_-]?)(@here)([.!$%&+/=?^`{|}_-]?)(?=\b)(?!([\w'#%+-]*@(?:[a-z\d-]+\.)+[a-z]{2,}(?:\s|$|@here))|((?:(?!|[^<]*(<\/pre>|<\/code>))/gm, ++ replacement: (_extras, match, g1, g2, g3) => { ++ if (!str_1.default.isValidMention(match)) { ++ return match; ++ } ++ return `${g1}${g2}${g3}`; ++ }, ++ }, ++ ++ { ++ name: 'commandSummarize', ++ regex: /^(\/summarize)(?=\s|$)/gm, ++ replacement: (_extras, match) => { ++ return `${match}`; ++ }, ++ }, + ]; + /** + * The list of rules that we have to exclude in shouldKeepWhitespaceRules list. +@@ -761,7 +821,7 @@ class ExpensiMark { + * @param [options.disabledRules=[]] - An array of name of rules as defined in this class. + * If not provided, all available rules will be applied. If provided, the rules in the array will be skipped. + */ +- replace(text, { filterRules = [], shouldEscapeText = true, shouldKeepRawInput = false, disabledRules = [], extras = EXTRAS_DEFAULT } = {}) { ++ replace(text, {filterRules = [], shouldEscapeText = true, shouldKeepRawInput = false, disabledRules = [], extras = EXTRAS_DEFAULT} = {}) { + // This ensures that any html the user puts into the comment field shows as raw html + let replacedText = shouldEscapeText ? Utils.escapeText(text) : text; + const rules = this.getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput); +@@ -773,8 +833,7 @@ class ExpensiMark { + const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; + if ('process' in rule) { + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); +- } +- else { ++ } else { + replacedText = this.replaceTextWithExtras(replacedText, rule.regex, extras, replacement); + } + // Post-process text after applying regex +@@ -784,9 +843,8 @@ class ExpensiMark { + }; + try { + rules.forEach(processRule); +- } +- catch (e) { +- ExpensiMark.Log.alert('Error replacing text with html in ExpensiMark.replace', { error: e }); ++ } catch (e) { ++ ExpensiMark.Log.alert('Error replacing text with html in ExpensiMark.replace', {error: e}); + // We want to return text without applying rules if exception occurs during replacing + return shouldEscapeText ? Utils.escapeText(text) : text; + } +@@ -807,8 +865,7 @@ class ExpensiMark { + for (let i = 0; i < url.length; i++) { + if (url[i] === '(') { + unmatchedOpenParentheses++; +- } +- else if (url[i] === ')') { ++ } else if (url[i] === ')') { + // Unmatched closing parenthesis + if (unmatchedOpenParentheses <= 0) { + const numberOfCharsToRemove = url.length - i; +@@ -828,8 +885,7 @@ class ExpensiMark { + for (let i = url.length - 1; i >= 0; i--) { + if (Constants.CONST.SPECIAL_CHARS_TO_REMOVE.includes(url[i])) { + numberOfCharsToRemove++; +- } +- else { ++ } else { + break; + } + } +@@ -850,10 +906,9 @@ class ExpensiMark { + const domainMatch = domainRegex.exec(url); + // If we find another domain in the remainder of the string, we apply the auto link rule again and set a flag to avoid re-doing below. + if (domainMatch !== null && domainMatch[3] !== '') { +- replacedText = replacedText.concat(domainMatch[1] + this.replace(domainMatch[3], { filterRules: ['autolink'] })); ++ replacedText = replacedText.concat(domainMatch[1] + this.replace(domainMatch[3], {filterRules: ['autolink']})); + shouldApplyAutoLinkAgain = false; +- } +- else { ++ } else { + // Otherwise, we're done applying rules + isDoneMatching = true; + } +@@ -862,8 +917,7 @@ class ExpensiMark { + // or if match[1] is multiline text preceeded by markdown heading, e.g., # [example\nexample\nexample](https://example.com) + if (isDoneMatching || match[1].includes('') || match[1].includes('

')) { + replacedText = replacedText.concat(textToCheck.substr(match.index, match[0].length)); +- } +- else if (shouldApplyAutoLinkAgain) { ++ } else if (shouldApplyAutoLinkAgain) { + const urlRegex = new RegExp(`^${UrlPatterns.LOOSE_URL_REGEX}$|^${UrlPatterns.URL_REGEX}$`, 'i'); + // `match[1]` contains the text inside the [] of the markdown e.g. [example](https://example.com) + // At the entry of function this.replace, text is already escaped due to the rules that precede the link +@@ -873,9 +927,9 @@ class ExpensiMark { + const linkText = urlRegex.test(match[1]) + ? match[1] + : this.replace(match[1], { +- filterRules: ['bold', 'strikethrough', 'italic'], +- shouldEscapeText: false, +- }); ++ filterRules: ['bold', 'strikethrough', 'italic'], ++ shouldEscapeText: false, ++ }); + replacedText = replacedText.concat(replacement(EXTRAS_DEFAULT, match[0], linkText, url)); + } + startIndex = match.index + match[0].length; +@@ -908,7 +962,7 @@ class ExpensiMark { + startIndex = match.index + match[0].length; + // Line breaks (`\n`) followed by empty contents are already removed + // but line breaks inside contents should be parsed to
to skip `autoEmail` rule +- replacedText = this.replace(replacedText, { filterRules: ['newline'], shouldEscapeText: false }); ++ replacedText = this.replace(replacedText, {filterRules: ['newline'], shouldEscapeText: false}); + // Now we move to the next match that the js regex found in the text + match = regex.exec(textToCheck); + } +@@ -926,7 +980,9 @@ class ExpensiMark { + */ + replaceBlockElementWithNewLine(htmlString) { + // eslint-disable-next-line max-len +- let splitText = htmlString.split(/|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>|
    |<\/blockquote>/); ++ let splitText = htmlString.split( ++ /|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/, ++ ); + const stripHTML = (text) => str_1.default.stripHTML(text); + splitText = splitText.map(stripHTML); + let joinedText = ''; +@@ -945,8 +1001,7 @@ class ExpensiMark { + // Insert '\n' unless it ends with '\n' or '>' or it's the last element, or if it's a header ('# ') with a space. + if ((nextItem && text.match(/>[\s]?$/) && !nextItem.startsWith('> ')) || text.match(/\n[\s]?$/) || index === splitText.length - 1 || text === '# ') { + joinedText += text; +- } +- else { ++ } else { + joinedText += `${text}\n`; + } + }; +@@ -984,25 +1039,25 @@ class ExpensiMark { + let count = 0; + parsedText = splittedText + .map((line) => { +- const hasBR = line.endsWith('
    '); +- if (line === '' && count === 0) { +- return ''; +- } +- const textLine = line.replace(/(
    )$/g, ''); +- if (textLine.startsWith('
    ')) { +- count += (textLine.match(/
    /g) || []).length; +- } +- if (textLine.endsWith('
    ')) { +- count -= (textLine.match(/<\/blockquote>/g) || []).length; ++ const hasBR = line.endsWith('
    '); ++ if (line === '' && count === 0) { ++ return ''; ++ } ++ const textLine = line.replace(/(
    )$/g, ''); ++ if (textLine.startsWith('
    ')) { ++ count += (textLine.match(/
    /g) || []).length; ++ } ++ if (textLine.endsWith('
    ')) { ++ count -= (textLine.match(/<\/blockquote>/g) || []).length; ++ if (count > 0) { ++ return `${textLine}${'
    '.repeat(count)}`; ++ } ++ } + if (count > 0) { +- return `${textLine}${'
    '.repeat(count)}`; ++ return `${textLine}${'
    '}${'
    '.repeat(count)}`; + } +- } +- if (count > 0) { +- return `${textLine}${'
    '}${'
    '.repeat(count)}`; +- } +- return textLine + (hasBR ? '
    ' : ''); +- }) ++ return textLine + (hasBR ? '
    ' : ''); ++ }) + .join(''); + return parsedText; + } +@@ -1040,6 +1095,7 @@ class ExpensiMark { + // Unescaping because the text is escaped in 'replace' function + // We use 'htmlDecode' instead of 'unescape' to replace entities like ' ' + replacedText = str_1.default.htmlDecode(replacedText); ++ + return replacedText; + } + /** +@@ -1066,7 +1122,7 @@ class ExpensiMark { + shouldKeepRawInput, + }); + this.currentQuoteDepth = 0; +- return { replacedText, shouldAddSpace: isStartingWithSpace }; ++ return {replacedText, shouldAddSpace: isStartingWithSpace}; + } + /** + * Check if the input text includes only the open or the close tag of an element. +@@ -1084,8 +1140,7 @@ class ExpensiMark { + if (openingTag && openingTag !== 'br') { + // If it's an opening tag, push it onto the stack + tagStack.push(openingTag); +- } +- else if (closingTag) { ++ } else if (closingTag) { + // If it's a closing tag, pop the top of the stack + const expectedTag = tagStack.pop(); + // If the closing tag doesn't match the expected opening tag, return false +@@ -1103,7 +1158,7 @@ class ExpensiMark { + */ + extractLinksInMarkdownComment(comment) { + try { +- const htmlString = this.replace(comment, { filterRules: ['link'] }); ++ const htmlString = this.replace(comment, {filterRules: ['link']}); + // We use same anchor tag template as link and autolink rules to extract link + const regex = new RegExp(``, 'gi'); + const matches = [...htmlString.matchAll(regex)]; +@@ -1111,9 +1166,8 @@ class ExpensiMark { + const sanitizeMatch = (match) => str_1.default.sanitizeURL(match[1]); + const links = matches.map(sanitizeMatch); + return links; +- } +- catch (e) { +- ExpensiMark.Log.alert('Error parsing url in ExpensiMark.extractLinksInMarkdownComment', { error: e }); ++ } catch (e) { ++ ExpensiMark.Log.alert('Error parsing url in ExpensiMark.extractLinksInMarkdownComment', {error: e}); + return undefined; + } + } +@@ -1156,8 +1210,7 @@ class ExpensiMark { + const defaultPosition = maxLength - totalLength; + // Define the slop value, which determines the tolerance for cutting off content near the maximum length + const slop = opts.slop; +- if (!slop) +- return defaultPosition; ++ if (!slop) return defaultPosition; + // Initialize the position to the default position + let position = defaultPosition; + // Determine if the default position is considered "short" based on the slop value +@@ -1173,8 +1226,7 @@ class ExpensiMark { + if (tailPosition && substr.length <= tailPosition) { + // If tail position is defined and the substring length is within the tail position, set position to the substring length + position = substr.length; +- } +- else { ++ } else { + // Iterate through word boundary matches to adjust the position + while (wordBreakMatch !== null) { + if (wordBreakMatch.index < slopPos) { +@@ -1183,13 +1235,11 @@ class ExpensiMark { + if (wordBreakMatch.index === 0 && defaultPosition <= 1) { + break; + } +- } +- else if (wordBreakMatch.index === slopPos) { ++ } else if (wordBreakMatch.index === slopPos) { + // If the word boundary is at the slop position, set position to the default position + position = defaultPosition; + break; +- } +- else { ++ } else { + // If the word boundary is after the slop position, adjust position forward + position = defaultPosition + (wordBreakMatch.index - slopPos); + break; +@@ -1233,7 +1283,7 @@ class ExpensiMark { + let tag; + let selfClose = null; + let htmlString = html; +- const opts = Object.assign({ ellipsis: DEFAULT_TRUNCATE_SYMBOL, truncateLastWord: true, slop: DEFAULT_SLOP }, options); ++ const opts = Object.assign({ellipsis: DEFAULT_TRUNCATE_SYMBOL, truncateLastWord: true, slop: DEFAULT_SLOP}, options); + function removeImageTag(content) { + const match = IMAGE_TAG_REGEX.exec(content); + if (!match) { +@@ -1247,8 +1297,8 @@ class ExpensiMark { + return tags + .reverse() + .map((mappedTag) => { +- return ``; +- }) ++ return ``; ++ }) + .join(''); + } + while (matches) { +@@ -1278,16 +1328,14 @@ class ExpensiMark { + if (totalLength + index > maxLength) { + truncatedContent += htmlString.substring(0, this.getEndPosition(htmlString, index, maxLength, totalLength, opts)); + break; +- } +- else { ++ } else { + totalLength += index; + truncatedContent += htmlString.substring(0, index); + } + if (endResult[1] === '/') { + tagsStack.pop(); + selfClose = null; +- } +- else { ++ } else { + selfClose = SELF_CLOSE_REGEX.exec(endResult); + if (!selfClose) { + tag = matches[1]; diff --git a/src/CONST.ts b/src/CONST.ts index b8b619d90b12..c3d37a704716 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1781,7 +1781,7 @@ const CONST = { MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER: 5, HERE_TEXT: '@here', SUGGESTION_BOX_MAX_SAFE_DISTANCE: 10, - BIG_SCREEN_SUGGESTION_WIDTH: 400, + BIG_SCREEN_SUGGESTION_WIDTH: 370, }, COMPOSER_MAX_HEIGHT: 125, CHAT_FOOTER_SECONDARY_ROW_HEIGHT: 15, diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 12b515194928..60b585d568d9 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -80,6 +80,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim 'mention-user': HTMLElementModel.fromCustomModel({tagName: 'mention-user', contentModel: HTMLContentModel.textual}), 'mention-report': HTMLElementModel.fromCustomModel({tagName: 'mention-report', contentModel: HTMLContentModel.textual}), 'mention-here': HTMLElementModel.fromCustomModel({tagName: 'mention-here', contentModel: HTMLContentModel.textual}), + command: HTMLElementModel.fromCustomModel({tagName: 'command', contentModel: HTMLContentModel.textual}), 'next-step': HTMLElementModel.fromCustomModel({ tagName: 'next-step', mixedUAStyles: {...styles.textLabelSupporting, ...styles.lh16}, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx new file mode 100644 index 000000000000..b8e3d7422e1a --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import type {TextStyle} from 'react-native'; +import {StyleSheet} from 'react-native'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; +import {TNodeChildrenRenderer} from 'react-native-render-html'; +import Text from '@components/Text'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; + +function CommandRenderer({style, tnode}: CustomRendererProps) { + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + + const flattenStyle = StyleSheet.flatten(style as TextStyle); + const {color, ...styleWithoutColor} = flattenStyle; + + return ( + + + + + + ); +} + +CommandRenderer.displayName = 'CommandRenderer'; + +export default CommandRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts index 91ed66f8b931..5a745b5ea36f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts @@ -1,6 +1,7 @@ import type {CustomTagRendererRecord} from 'react-native-render-html'; import AnchorRenderer from './AnchorRenderer'; import CodeRenderer from './CodeRenderer'; +import CommandRenderer from './CommandRenderer'; import DeletedActionRenderer from './DeletedActionRenderer'; import EditedRenderer from './EditedRenderer'; import EmojiRenderer from './EmojiRenderer'; @@ -29,6 +30,7 @@ const HTMLEngineProviderComponentList: CustomTagRendererRecord = { 'mention-user': MentionUserRenderer, 'mention-report': MentionReportRenderer, 'mention-here': MentionHereRenderer, + command: CommandRenderer, emoji: EmojiRenderer, 'next-step-email': NextStepEmailRenderer, 'deleted-action': DeletedActionRenderer, diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index 8e2ad1774a78..5b596aaebdbf 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -76,6 +76,10 @@ function useMarkdownStyle(message: string | null = null, excludeStyles: Array Date: Fri, 7 Feb 2025 16:09:06 -0800 Subject: [PATCH 17/29] workaround for comments --- .../HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx | 5 ++--- src/pages/home/report/comment/TextCommentFragment.tsx | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx index b8e3d7422e1a..f74a5c1524f5 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx @@ -2,12 +2,11 @@ import React from 'react'; import type {TextStyle} from 'react-native'; import {StyleSheet} from 'react-native'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; -import {TNodeChildrenRenderer} from 'react-native-render-html'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; -function CommandRenderer({style, tnode}: CustomRendererProps) { +function CommandRenderer({style}: CustomRendererProps) { const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -20,7 +19,7 @@ function CommandRenderer({style, tnode}: CustomRendererProps) color={theme.ourMentionText} style={[styleWithoutColor, StyleUtils.getMentionStyle(true)]} > - + /summarize ); diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index f8ea9b56871f..c997e92a5a92 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -84,6 +84,12 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so htmlWithTag = `${htmlWithTag}`; } + // workaround for now, we will have a better solution for this + const cmd = CONST.COMPOSER_COMMANDS.at(0)?.command ?? ''; + if (htmlWithTag.startsWith(cmd)) { + htmlWithTag = `${htmlWithTag.slice(cmd.length)}`; + } + return ( Date: Fri, 7 Feb 2025 16:29:01 -0800 Subject: [PATCH 18/29] Remove workaround --- src/pages/home/report/comment/TextCommentFragment.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index c997e92a5a92..e13edbc210db 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -45,6 +45,10 @@ type TextCommentFragmentProps = { iouMessage?: string; }; +function removeLeadingCommand(text: string) { + return `${text.replace(/^(\/summarize)<\/command>/gm, '')}`; +} + function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, source, style, displayAsGroup, iouMessage = ''}: TextCommentFragmentProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -67,6 +71,7 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so // on other device, only render it as text if the only difference is
    tag const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text ?? ''); const containsEmojis = CONST.REGEX.ALL_EMOJIS.test(text ?? ''); + if (!shouldRenderAsText(html, text ?? '') && !(containsOnlyEmojis && styleAsDeleted)) { const editedTag = fragment?.isEdited ? `` : ''; const htmlWithDeletedTag = styleAsDeleted ? `${html}` : html; @@ -84,11 +89,7 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so htmlWithTag = `${htmlWithTag}`; } - // workaround for now, we will have a better solution for this - const cmd = CONST.COMPOSER_COMMANDS.at(0)?.command ?? ''; - if (htmlWithTag.startsWith(cmd)) { - htmlWithTag = `${htmlWithTag.slice(cmd.length)}`; - } + htmlWithTag = removeLeadingCommand(htmlWithTag); return ( Date: Fri, 7 Feb 2025 16:30:13 -0800 Subject: [PATCH 19/29] Add new patch --- patches/expensify-common+2.0.115+001+hackathon.patch | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/patches/expensify-common+2.0.115+001+hackathon.patch b/patches/expensify-common+2.0.115+001+hackathon.patch index 909b433b40c2..e4c2f2af9ff9 100644 --- a/patches/expensify-common+2.0.115+001+hackathon.patch +++ b/patches/expensify-common+2.0.115+001+hackathon.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/expensify-common/dist/ExpensiMark.js b/node_modules/expensify-common/dist/ExpensiMark.js -index 5c8cd83..7df90cd 100644 +index 5c8cd83..910b42b 100644 --- a/node_modules/expensify-common/dist/ExpensiMark.js +++ b/node_modules/expensify-common/dist/ExpensiMark.js @@ -1,41 +1,61 @@ @@ -275,10 +275,10 @@ index 5c8cd83..7df90cd 100644 + }, + + { -+ name: 'commandSummarize', -+ regex: /^(\/summarize)(?=\s|$)/gm, ++ name: 'removeCommandTags', ++ regex: /(\/summarize)<\/command>/gm, + replacement: (_extras, match) => { -+ return `${match}`; ++ return match.replace(/<\/?command>/g, ''); // Removes tags + }, + }, ]; From db7feec4988e1f680b2d6819f7e229aed8f692b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 7 Feb 2025 16:44:17 -0800 Subject: [PATCH 20/29] Implement Concierge AI button --- src/languages/en.ts | 1 + src/languages/es.ts | 1 + .../ComposerWithSuggestions.tsx | 4 +- .../ReportActionCompose/ConciergeAIButton.tsx | 50 +++++++++++++++++++ .../ReportActionCompose.tsx | 14 +++++- src/styles/index.ts | 9 ++++ 6 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/ConciergeAIButton.tsx diff --git a/src/languages/en.ts b/src/languages/en.ts index 05a78bd8a432..ac8b787f51b1 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -675,6 +675,7 @@ const translations = { emoji: 'Emoji', collapse: 'Collapse', expand: 'Expand', + conciergeAI: 'Concierge AI', }, reportActionContextMenu: { copyToClipboard: 'Copy to clipboard', diff --git a/src/languages/es.ts b/src/languages/es.ts index e9c86672358b..c9e3ee5cdccf 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -667,6 +667,7 @@ const translations = { emoji: 'Emoji', collapse: 'Colapsar', expand: 'Expandir', + conciergeAI: 'Concierge AI', }, reportActionContextMenu: { copyToClipboard: 'Copiar al portapapeles', diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index f88c39fa9457..455575a8355b 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -155,7 +155,7 @@ type SwitchToCurrentReportProps = { type ComposerRef = { blur: () => void; focus: (shouldDelay?: boolean) => void; - replaceSelectionWithText: EmojiPickerActions.OnEmojiSelected; + replaceSelectionWithText: (text: string) => void; getCurrentText: () => string; isFocused: () => boolean; /** @@ -163,6 +163,7 @@ type ComposerRef = { * Once the composer ahs cleared onCleared will be called with the value that was cleared. */ clear: () => void; + setSelection: React.Dispatch>; }; const {RNTextInputReset} = NativeModules; @@ -700,6 +701,7 @@ function ComposerWithSuggestions( isFocused: () => !!textInputRef.current?.isFocused(), clear, getCurrentText, + setSelection, }), [blur, clear, focus, replaceSelectionWithText, getCurrentText], ); diff --git a/src/pages/home/report/ReportActionCompose/ConciergeAIButton.tsx b/src/pages/home/report/ReportActionCompose/ConciergeAIButton.tsx new file mode 100644 index 000000000000..b1fb9fe08852 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ConciergeAIButton.tsx @@ -0,0 +1,50 @@ +import {useIsFocused} from '@react-navigation/native'; +import React, {memo} from 'react'; +import type {GestureResponderEvent} from 'react-native'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getButtonState from '@libs/getButtonState'; + +type ConciergeAIButtonProps = { + /** A callback function when the button is pressed */ + onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; +}; + +function ConciergeAIButton({onPress}: ConciergeAIButtonProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const isFocused = useIsFocused(); + + return ( + + [styles.chatItemConciergeAIButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} + onPress={(e) => { + if (!isFocused) { + return; + } + + onPress?.(e); + }} + accessibilityLabel={translate('reportActionCompose.conciergeAI')} + > + {({hovered, pressed}) => ( + + )} + + + ); +} + +ConciergeAIButton.displayName = 'ConciergeAIButton'; + +export default memo(ConciergeAIButton); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 26d37bee8886..f6f5ed6aeeb1 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -54,6 +54,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef, ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import ConciergeAIButton from './ConciergeAIButton'; import SendButton from './SendButton'; type SuggestionsRef = { @@ -533,6 +534,15 @@ function ReportActionCompose({ )} + {isCommentEmpty && ( + { + focus(); + composerRef.current?.replaceSelectionWithText('/'); + composerRef.current?.setSelection({start: 1, end: 1, positionX: 1, positionY: 0}); + }} + /> + )} {canUseTouchScreen() && isMediumScreenWidth ? null : ( composerRef.current?.replaceSelectionWithText(...args)} + onEmojiSelected={(emojiCode: string) => { + composerRef.current?.replaceSelectionWithText(emojiCode); + }} emojiPickerID={report?.reportID} shiftVertical={emojiShiftVertical} /> diff --git a/src/styles/index.ts b/src/styles/index.ts index 8ae9d54359fc..9cd7d29a1025 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2356,6 +2356,15 @@ const styles = (theme: ThemeColors) => justifyContent: 'center', }, + chatItemConciergeAIButton: { + alignSelf: 'flex-end', + borderRadius: variables.buttonBorderRadius, + height: 40, + marginVertical: 3, + paddingHorizontal: 10, + justifyContent: 'center', + }, + editChatItemEmojiWrapper: { marginRight: 3, alignSelf: 'flex-end', From b8f1248ccbd4111629d3f8da012f8df54177e22b Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 16:57:45 -0800 Subject: [PATCH 21/29] Fix patch --- ...-live-markdown+0.1.230+001+hackathon.patch | 745 ------------------ ...y+react-native-live-markdown+0.1.230.patch | 55 ++ src/CONST.ts | 2 +- 3 files changed, 56 insertions(+), 746 deletions(-) delete mode 100644 patches/@expensify+react-native-live-markdown+0.1.230+001+hackathon.patch create mode 100644 patches/@expensify+react-native-live-markdown+0.1.230.patch diff --git a/patches/@expensify+react-native-live-markdown+0.1.230+001+hackathon.patch b/patches/@expensify+react-native-live-markdown+0.1.230+001+hackathon.patch deleted file mode 100644 index c87e175c385e..000000000000 --- a/patches/@expensify+react-native-live-markdown+0.1.230+001+hackathon.patch +++ /dev/null @@ -1,745 +0,0 @@ -diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js b/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js -index f492c22..3c3712e 100644 ---- a/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js -+++ b/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js -@@ -1,249 +1,272 @@ - 'worklet'; - --import { Platform } from 'react-native'; --import { ExpensiMark } from 'expensify-common'; --import { unescapeText } from 'expensify-common/dist/utils'; --import { decode } from 'html-entities'; --import { groupRanges, sortRanges, splitRangesOnEmojis } from './rangeUtils'; -+import {ExpensiMark} from 'expensify-common'; -+import {unescapeText} from 'expensify-common/dist/utils'; -+import {decode} from 'html-entities'; -+import {Platform} from 'react-native'; -+import {groupRanges, sortRanges, splitRangesOnEmojis} from './rangeUtils'; -+ - function isWeb() { -- return Platform.OS === 'web'; -+ return Platform.OS === 'web'; - } - function isJest() { -- return !!global.process.env.JEST_WORKER_ID; -+ return !!global.process.env.JEST_WORKER_ID; - } - - // eslint-disable-next-line no-underscore-dangle - if (__DEV__ && !isWeb() && !isJest() && decode.__workletHash === undefined) { -- throw new Error("[react-native-live-markdown] `parseExpensiMark` requires `html-entities` package to be workletized. Please add `'worklet';` directive at the top of `node_modules/html-entities/lib/index.js` using patch-package."); -+ throw new Error( -+ "[react-native-live-markdown] `parseExpensiMark` requires `html-entities` package to be workletized. Please add `'worklet';` directive at the top of `node_modules/html-entities/lib/index.js` using patch-package.", -+ ); - } - const MAX_PARSABLE_LENGTH = 4000; - function parseMarkdownToHTML(markdown) { -- const parser = new ExpensiMark(); -- const html = parser.replace(markdown, { -- shouldKeepRawInput: true -- }); -- return html; -+ const parser = new ExpensiMark(); -+ const html = parser.replace(markdown, { -+ shouldKeepRawInput: true, -+ }); -+ -+ return html; - } - function parseHTMLToTokens(html) { -- const tokens = []; -- let left = 0; -- // eslint-disable-next-line no-constant-condition -- while (true) { -- const open = html.indexOf('<', left); -- if (open === -1) { -- if (left < html.length) { -- tokens.push(['TEXT', html.substring(left)]); -- } -- break; -- } -- if (open !== left) { -- tokens.push(['TEXT', html.substring(left, open)]); -- } -- const close = html.indexOf('>', open); -- if (close === -1) { -- throw new Error('[react-native-live-markdown] Error in function parseHTMLToTokens: Invalid HTML: no matching ">"'); -+ const tokens = []; -+ let left = 0; -+ // eslint-disable-next-line no-constant-condition -+ while (true) { -+ const open = html.indexOf('<', left); -+ if (open === -1) { -+ if (left < html.length) { -+ tokens.push(['TEXT', html.substring(left)]); -+ } -+ break; -+ } -+ if (open !== left) { -+ tokens.push(['TEXT', html.substring(left, open)]); -+ } -+ const close = html.indexOf('>', open); -+ if (close === -1) { -+ throw new Error('[react-native-live-markdown] Error in function parseHTMLToTokens: Invalid HTML: no matching ">"'); -+ } -+ tokens.push(['HTML', html.substring(open, close + 1)]); -+ left = close + 1; - } -- tokens.push(['HTML', html.substring(open, close + 1)]); -- left = close + 1; -- } -- return tokens; -+ return tokens; - } - function parseTokensToTree(tokens) { -- const stack = [{ -- tag: '<>', -- children: [] -- }]; -- tokens.forEach(([type, payload]) => { -- if (type === 'TEXT') { -- const text = unescapeText(payload); -- const top = stack[stack.length - 1]; -- top.children.push(text); -- } else if (type === 'HTML') { -- if (payload.startsWith('')) { -- // self-closing tag -- const top = stack[stack.length - 1]; -- top.children.push({ -- tag: payload, -- children: [] -- }); -- } else { -- // opening tag -- stack.push({ -- tag: payload, -- children: [] -- }); -- } -- } else { -- throw new Error(`[react-native-live-markdown] Error in function parseTokensToTree: Unknown token type: ${type}. Expected 'TEXT' or 'HTML'. Please ensure tokens only contain these types.`); -+ const stack = [ -+ { -+ tag: '<>', -+ children: [], -+ }, -+ ]; -+ tokens.forEach(([type, payload]) => { -+ if (type === 'TEXT') { -+ const text = unescapeText(payload); -+ const top = stack[stack.length - 1]; -+ top.children.push(text); -+ } else if (type === 'HTML') { -+ if (payload.startsWith('')) { -+ // self-closing tag -+ const top = stack[stack.length - 1]; -+ top.children.push({ -+ tag: payload, -+ children: [], -+ }); -+ } else { -+ // opening tag -+ stack.push({ -+ tag: payload, -+ children: [], -+ }); -+ } -+ } else { -+ throw new Error( -+ `[react-native-live-markdown] Error in function parseTokensToTree: Unknown token type: ${type}. Expected 'TEXT' or 'HTML'. Please ensure tokens only contain these types.`, -+ ); -+ } -+ }); -+ if (stack.length !== 1) { -+ const unclosedTags = -+ stack.length > 0 -+ ? stack -+ .slice(1) -+ .map((item) => item.tag) -+ .join(', ') -+ : ''; -+ throw new Error( -+ `[react-native-live-markdown] Invalid HTML structure: the following tags are not properly closed: ${unclosedTags}. Ensure each opening tag has a corresponding closing tag.`, -+ ); - } -- }); -- if (stack.length !== 1) { -- const unclosedTags = stack.length > 0 ? stack.slice(1).map(item => item.tag).join(', ') : ''; -- throw new Error(`[react-native-live-markdown] Invalid HTML structure: the following tags are not properly closed: ${unclosedTags}. Ensure each opening tag has a corresponding closing tag.`); -- } -- return stack[0]; -+ return stack[0]; - } - function parseTreeToTextAndRanges(tree) { -- let text = ''; -- function processChildren(node) { -- if (typeof node === 'string') { -- text += node; -- } else { -- node.children.forEach(dfs); -- } -- } -- function appendSyntax(syntax) { -- addChildrenWithStyle(syntax, 'syntax'); -- } -- function addChildrenWithStyle(node, type) { -- const start = text.length; -- processChildren(node); -- const end = text.length; -- ranges.push({ -- type, -- start, -- length: end - start -- }); -- } -- const ranges = []; -- function dfs(node) { -- if (typeof node === 'string') { -- text += node; -- } else { -- // eslint-disable-next-line no-lonely-if -- if (node.tag === '<>') { -- processChildren(node); -- } else if (node.tag === '') { -- appendSyntax('*'); -- addChildrenWithStyle(node, 'bold'); -- appendSyntax('*'); -- } else if (node.tag === '') { -- appendSyntax('_'); -- addChildrenWithStyle(node, 'italic'); -- appendSyntax('_'); -- } else if (node.tag === '') { -- appendSyntax('~'); -- addChildrenWithStyle(node, 'strikethrough'); -- appendSyntax('~'); -- } else if (node.tag === '') { -- addChildrenWithStyle(node, 'emoji'); -- } else if (node.tag === '') { -- appendSyntax('`'); -- addChildrenWithStyle(node, 'code'); -- appendSyntax('`'); -- } else if (node.tag === '') { -- addChildrenWithStyle(node, 'mention-here'); -- } else if (node.tag === '') { -- addChildrenWithStyle(node, 'mention-user'); -- } else if (node.tag === '') { -- addChildrenWithStyle(node, 'mention-short'); -- } else if (node.tag === '') { -- addChildrenWithStyle(node, 'mention-report'); -- } else if (node.tag === '
    ') { -- appendSyntax('>'); -- addChildrenWithStyle(node, 'blockquote'); -- // compensate for "> " at the beginning -- if (ranges.length > 0) { -- const curr = ranges[ranges.length - 1]; -- curr.start -= 1; -- curr.length += 1; -- } -- } else if (node.tag === '

    ') { -- appendSyntax('# '); -- addChildrenWithStyle(node, 'h1'); -- } else if (node.tag === '
    ') { -- text += '\n'; -- } else if (node.tag.startsWith(' processChildren(child)); -- appendSyntax(']'); -+ } -+ const ranges = []; -+ function dfs(node) { -+ if (typeof node === 'string') { -+ text += node; -+ } else { -+ // eslint-disable-next-line no-lonely-if -+ if (node.tag === '<>') { -+ processChildren(node); -+ } else if (node.tag === '') { -+ appendSyntax('*'); -+ addChildrenWithStyle(node, 'bold'); -+ appendSyntax('*'); -+ } else if (node.tag === '') { -+ appendSyntax('_'); -+ addChildrenWithStyle(node, 'italic'); -+ appendSyntax('_'); -+ } else if (node.tag === '') { -+ appendSyntax('~'); -+ addChildrenWithStyle(node, 'strikethrough'); -+ appendSyntax('~'); -+ } else if (node.tag === '') { -+ addChildrenWithStyle(node, 'emoji'); -+ } else if (node.tag === '') { -+ appendSyntax('`'); -+ addChildrenWithStyle(node, 'code'); -+ appendSyntax('`'); -+ } else if (node.tag === '') { -+ addChildrenWithStyle(node, 'mention-here'); -+ } else if (node.tag === '') { -+ addChildrenWithStyle(node, 'mention-user'); -+ } else if (node.tag === '') { -+ addChildrenWithStyle(node, 'mention-short'); -+ } else if (node.tag === '') { -+ addChildrenWithStyle(node, 'command'); -+ } else if (node.tag === '') { -+ addChildrenWithStyle(node, 'mention-report'); -+ } else if (node.tag === '
    ') { -+ appendSyntax('>'); -+ addChildrenWithStyle(node, 'blockquote'); -+ // compensate for "> " at the beginning -+ if (ranges.length > 0) { -+ const curr = ranges[ranges.length - 1]; -+ curr.start -= 1; -+ curr.length += 1; -+ } -+ } else if (node.tag === '

    ') { -+ appendSyntax('# '); -+ addChildrenWithStyle(node, 'h1'); -+ } else if (node.tag === '
    ') { -+ text += '\n'; -+ } else if (node.tag.startsWith(' processChildren(child)); -+ appendSyntax(']'); -+ } -+ appendSyntax('('); -+ addChildrenWithStyle(linkString, 'link'); -+ appendSyntax(')'); -+ } else { -+ throw new Error(`[react-native-live-markdown] Error in function parseTreeToTextAndRanges: Unknown tag '${node.tag}'. This tag is not supported in this function's logic.`); -+ } - } -- appendSyntax('('); -- addChildrenWithStyle(linkString, 'link'); -- appendSyntax(')'); -- } else { -- throw new Error(`[react-native-live-markdown] Error in function parseTreeToTextAndRanges: Unknown tag '${node.tag}'. This tag is not supported in this function's logic.`); -- } - } -- } -- dfs(tree); -- return [text, ranges]; -+ dfs(tree); -+ return [text, ranges]; - } - const isAndroid = Platform.OS === 'android'; - function parseExpensiMark(markdown) { -- if (markdown.length > MAX_PARSABLE_LENGTH) { -- return []; -- } -- const html = parseMarkdownToHTML(markdown); -- const tokens = parseHTMLToTokens(html); -- const tree = parseTokensToTree(tokens); -- const [text, ranges] = parseTreeToTextAndRanges(tree); -- if (text !== markdown) { -- console.error(`[react-native-live-markdown] Parsing error: the processed text does not match the original Markdown input. This may be caused by incorrect parsing functions or invalid input Markdown.\nProcessed input: '${JSON.stringify(text)}'\nOriginal input: '${JSON.stringify(markdown)}'`); -- return []; -- } -- let markdownRanges = sortRanges(ranges); -- if (isAndroid) { -- // Blocks applying italic and strikethrough styles to emojis on Android -- // TODO: Remove this condition when splitting emojis inside the inline code block will be fixed on the web -- markdownRanges = splitRangesOnEmojis(markdownRanges, 'italic'); -- markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); -- } -- const groupedRanges = groupRanges(markdownRanges); -- return groupedRanges; -+ if (markdown.length > MAX_PARSABLE_LENGTH) { -+ return []; -+ } -+ const html = parseMarkdownToHTML(markdown); -+ const tokens = parseHTMLToTokens(html); -+ const tree = parseTokensToTree(tokens); -+ const [text, ranges] = parseTreeToTextAndRanges(tree); -+ if (text !== markdown) { -+ console.error( -+ `[react-native-live-markdown] Parsing error: the processed text does not match the original Markdown input. This may be caused by incorrect parsing functions or invalid input Markdown.\nProcessed input: '${JSON.stringify( -+ text, -+ )}'\nOriginal input: '${JSON.stringify(markdown)}'`, -+ ); -+ return []; -+ } -+ let markdownRanges = sortRanges(ranges); -+ if (isAndroid) { -+ // Blocks applying italic and strikethrough styles to emojis on Android -+ // TODO: Remove this condition when splitting emojis inside the inline code block will be fixed on the web -+ markdownRanges = splitRangesOnEmojis(markdownRanges, 'italic'); -+ markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); -+ } -+ const groupedRanges = groupRanges(markdownRanges); -+ return groupedRanges; - } - export default parseExpensiMark; - //# sourceMappingURL=parseExpensiMark.js.map -diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js -index 1ab9c63..4f92154 100644 ---- a/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js -+++ b/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js -@@ -39,6 +39,10 @@ function makeDefaultMarkdownStyle() { - color: 'green', - backgroundColor: 'lime' - }, -+ command: { -+ color: 'green', -+ backgroundColor: 'lime' -+ }, - mentionUser: { - color: 'blue', - backgroundColor: 'cyan' -diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js -index ad119d2..f738be8 100644 ---- a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js -+++ b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js -@@ -1,113 +1,118 @@ --import { addInlineImagePreview } from '../inputElements/inlineImage'; -+import {addInlineImagePreview} from '../inputElements/inlineImage'; -+ - function addStyleToBlock(targetElement, type, markdownStyle, isMultiline = true) { -- const node = targetElement; -- switch (type) { -- case 'line': -- Object.assign(node.style, { -- margin: '0', -- padding: '0' -- }); -- break; -- case 'syntax': -- Object.assign(node.style, markdownStyle.syntax); -- break; -- case 'bold': -- node.style.fontWeight = 'bold'; -- break; -- case 'italic': -- node.style.fontStyle = 'italic'; -- break; -- case 'strikethrough': -- node.style.textDecoration = 'line-through'; -- break; -- case 'emoji': -- Object.assign(node.style, { -- ...markdownStyle.emoji, -- verticalAlign: 'middle', -- fontStyle: 'normal', -- // remove italic -- textDecoration: 'none', -- // remove strikethrough -- display: 'inline-block' -- }); -- break; -- case 'mention-here': -- Object.assign(node.style, markdownStyle.mentionHere); -- break; -- case 'mention-user': -- Object.assign(node.style, markdownStyle.mentionUser); -- break; -- case 'mention-report': -- Object.assign(node.style, markdownStyle.mentionReport); -- break; -- case 'link': -- Object.assign(node.style, { -- ...markdownStyle.link, -- textDecoration: 'underline' -- }); -- break; -- case 'code': -- Object.assign(node.style, markdownStyle.code); -- break; -- case 'pre': -- Object.assign(node.style, markdownStyle.pre); -- break; -- case 'blockquote': -- Object.assign(node.style, { -- ...markdownStyle.blockquote, -- borderLeftStyle: 'solid', -- display: 'inline-block', -- maxWidth: '100%', -- boxSizing: 'border-box', -- overflowWrap: 'anywhere' -- }); -- break; -- case 'h1': -- Object.assign(node.style, { -- ...markdownStyle.h1, -- fontWeight: 'bold' -- }); -- break; -- case 'block': -- Object.assign(node.style, { -- display: 'block', -- margin: '0', -- padding: '0', -- position: 'relative' -- }); -- break; -- case 'text': -- if (!isMultiline) { -- var _targetElement$parent, _targetElement$parent2; -- Object.assign(node.style, { -- backgroundColor: (_targetElement$parent = targetElement.parentElement) === null || _targetElement$parent === void 0 ? void 0 : _targetElement$parent.style.backgroundColor -- }); -- Object.assign(((_targetElement$parent2 = targetElement.parentElement) === null || _targetElement$parent2 === void 0 ? void 0 : _targetElement$parent2.style) ?? {}, { -- backgroundColor: 'transparent' -- }); -- } -- break; -- default: -- break; -- } -+ const node = targetElement; -+ switch (type) { -+ case 'line': -+ Object.assign(node.style, { -+ margin: '0', -+ padding: '0', -+ }); -+ break; -+ case 'syntax': -+ Object.assign(node.style, markdownStyle.syntax); -+ break; -+ case 'bold': -+ node.style.fontWeight = 'bold'; -+ break; -+ case 'italic': -+ node.style.fontStyle = 'italic'; -+ break; -+ case 'strikethrough': -+ node.style.textDecoration = 'line-through'; -+ break; -+ case 'emoji': -+ Object.assign(node.style, { -+ ...markdownStyle.emoji, -+ verticalAlign: 'middle', -+ fontStyle: 'normal', -+ // remove italic -+ textDecoration: 'none', -+ // remove strikethrough -+ display: 'inline-block', -+ }); -+ break; -+ case 'mention-here': -+ Object.assign(node.style, markdownStyle.mentionHere); -+ break; -+ case 'command': -+ Object.assign(node.style, markdownStyle.command); -+ break; -+ case 'mention-user': -+ Object.assign(node.style, markdownStyle.mentionUser); -+ break; -+ case 'mention-report': -+ Object.assign(node.style, markdownStyle.mentionReport); -+ break; -+ case 'link': -+ Object.assign(node.style, { -+ ...markdownStyle.link, -+ textDecoration: 'underline', -+ }); -+ break; -+ case 'code': -+ Object.assign(node.style, markdownStyle.code); -+ break; -+ case 'pre': -+ Object.assign(node.style, markdownStyle.pre); -+ break; -+ case 'blockquote': -+ Object.assign(node.style, { -+ ...markdownStyle.blockquote, -+ borderLeftStyle: 'solid', -+ display: 'inline-block', -+ maxWidth: '100%', -+ boxSizing: 'border-box', -+ overflowWrap: 'anywhere', -+ }); -+ break; -+ case 'h1': -+ Object.assign(node.style, { -+ ...markdownStyle.h1, -+ fontWeight: 'bold', -+ }); -+ break; -+ case 'block': -+ Object.assign(node.style, { -+ display: 'block', -+ margin: '0', -+ padding: '0', -+ position: 'relative', -+ }); -+ break; -+ case 'text': -+ if (!isMultiline) { -+ var _targetElement$parent, _targetElement$parent2; -+ Object.assign(node.style, { -+ backgroundColor: -+ (_targetElement$parent = targetElement.parentElement) === null || _targetElement$parent === void 0 ? void 0 : _targetElement$parent.style.backgroundColor, -+ }); -+ Object.assign(((_targetElement$parent2 = targetElement.parentElement) === null || _targetElement$parent2 === void 0 ? void 0 : _targetElement$parent2.style) ?? {}, { -+ backgroundColor: 'transparent', -+ }); -+ } -+ break; -+ default: -+ break; -+ } - } - const BLOCK_MARKDOWN_TYPES = ['inline-image']; - const FULL_LINE_MARKDOWN_TYPES = ['blockquote']; - function isBlockMarkdownType(type) { -- return BLOCK_MARKDOWN_TYPES.includes(type); -+ return BLOCK_MARKDOWN_TYPES.includes(type); - } - function getFirstBlockMarkdownRange(ranges) { -- const blockMarkdownRange = ranges.find(r => isBlockMarkdownType(r.type) || FULL_LINE_MARKDOWN_TYPES.includes(r.type)); -- return FULL_LINE_MARKDOWN_TYPES.includes((blockMarkdownRange === null || blockMarkdownRange === void 0 ? void 0 : blockMarkdownRange.type) || '') ? undefined : blockMarkdownRange; -+ const blockMarkdownRange = ranges.find((r) => isBlockMarkdownType(r.type) || FULL_LINE_MARKDOWN_TYPES.includes(r.type)); -+ return FULL_LINE_MARKDOWN_TYPES.includes((blockMarkdownRange === null || blockMarkdownRange === void 0 ? void 0 : blockMarkdownRange.type) || '') ? undefined : blockMarkdownRange; - } - function extendBlockStructure(currentInput, targetNode, currentRange, ranges, text, markdownStyle, inlineImagesProps) { -- switch (currentRange.type) { -- case 'inline-image': -- return addInlineImagePreview(currentInput, targetNode, text, ranges, markdownStyle, inlineImagesProps); -- default: -- break; -- } -- return targetNode; -+ switch (currentRange.type) { -+ case 'inline-image': -+ return addInlineImagePreview(currentInput, targetNode, text, ranges, markdownStyle, inlineImagesProps); -+ default: -+ break; -+ } -+ return targetNode; - } --export { addStyleToBlock, extendBlockStructure, isBlockMarkdownType, getFirstBlockMarkdownRange }; -+export {addStyleToBlock, extendBlockStructure, isBlockMarkdownType, getFirstBlockMarkdownRange}; - //# sourceMappingURL=blockUtils.js.map -diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js -index dcbe8fa..c096d8f 100644 ---- a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js -+++ b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js -@@ -76,7 +76,7 @@ function addTextToElement(node, text, isMultiline = true) { - span.appendChild(document.createTextNode(line)); - appendNode(span, node, 'text', line.length); - const parentType = (_span$parentElement = span.parentElement) === null || _span$parentElement === void 0 ? void 0 : _span$parentElement.dataset.type; -- if (!isMultiline && parentType && ['pre', 'code', 'mention-here', 'mention-user', 'mention-report'].includes(parentType)) { -+ if (!isMultiline && parentType && ['pre', 'code', 'mention-here', 'mention-user', 'mention-report', 'command'].includes(parentType)) { - // this is a fix to background colors being shifted downwards in a singleline input - addStyleToBlock(span, 'text', {}, false); - } diff --git a/patches/@expensify+react-native-live-markdown+0.1.230.patch b/patches/@expensify+react-native-live-markdown+0.1.230.patch new file mode 100644 index 000000000000..29bab7bec62e --- /dev/null +++ b/patches/@expensify+react-native-live-markdown+0.1.230.patch @@ -0,0 +1,55 @@ +diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js b/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js +index f492c22..f31996c 100644 +--- a/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js ++++ b/node_modules/@expensify/react-native-live-markdown/lib/module/parseExpensiMark.js +@@ -142,6 +142,8 @@ function parseTreeToTextAndRanges(tree) { + addChildrenWithStyle(node, 'mention-user'); + } else if (node.tag === '') { + addChildrenWithStyle(node, 'mention-short'); ++ } else if (node.tag === '') { ++ addChildrenWithStyle(node, 'command'); + } else if (node.tag === '') { + addChildrenWithStyle(node, 'mention-report'); + } else if (node.tag === '
    ') { +diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js +index 1ab9c63..74a89d2 100644 +--- a/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js ++++ b/node_modules/@expensify/react-native-live-markdown/lib/module/styleUtils.js +@@ -47,6 +47,10 @@ function makeDefaultMarkdownStyle() { + color: 'red', + backgroundColor: 'pink' + }, ++ command: { ++ color: 'green', ++ backgroundColor: 'lime' ++ }, + inlineImage: { + minWidth: 50, + minHeight: 50, +diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js +index ad119d2..a47fce9 100644 +--- a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js ++++ b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/blockUtils.js +@@ -34,6 +34,9 @@ function addStyleToBlock(targetElement, type, markdownStyle, isMultiline = true) + case 'mention-here': + Object.assign(node.style, markdownStyle.mentionHere); + break; ++ case 'command': ++ Object.assign(node.style, markdownStyle.command); ++ break; + case 'mention-user': + Object.assign(node.style, markdownStyle.mentionUser); + break; +diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js +index dcbe8fa..c096d8f 100644 +--- a/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js ++++ b/node_modules/@expensify/react-native-live-markdown/lib/module/web/utils/parserUtils.js +@@ -76,7 +76,7 @@ function addTextToElement(node, text, isMultiline = true) { + span.appendChild(document.createTextNode(line)); + appendNode(span, node, 'text', line.length); + const parentType = (_span$parentElement = span.parentElement) === null || _span$parentElement === void 0 ? void 0 : _span$parentElement.dataset.type; +- if (!isMultiline && parentType && ['pre', 'code', 'mention-here', 'mention-user', 'mention-report'].includes(parentType)) { ++ if (!isMultiline && parentType && ['pre', 'code', 'mention-here', 'mention-user', 'mention-report', 'command'].includes(parentType)) { + // this is a fix to background colors being shifted downwards in a singleline input + addStyleToBlock(span, 'text', {}, false); + } diff --git a/src/CONST.ts b/src/CONST.ts index c3d37a704716..3aceec40f7ff 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -358,7 +358,7 @@ const COMPOSER_COMMANDS: ComposerCommand[] = [ { command: '/insight', action: 'insight', - icon: Expensicons.Mute, + icon: Expensicons.Table, descriptionKey: 'composer.commands.insight', disabled: true, }, From 5175457f0640a85896a86e112daac1205d725bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 7 Feb 2025 17:09:43 -0800 Subject: [PATCH 22/29] Dont allow command suggestion during editing --- .../report/ReportActionCompose/Suggestions.tsx | 18 ++++++++++++------ .../report/ReportActionItemMessageEdit.tsx | 1 + 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx index 1b30ee2d1033..367603762977 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/Suggestions.tsx @@ -44,6 +44,9 @@ type SuggestionProps = { /** The policyID of the report connected to current composer */ policyID?: string; + + /** If the user is editing the comment */ + isEditingComment?: boolean; }; /** @@ -63,6 +66,7 @@ function Suggestions( isComposerFocused, isGroupPolicyReport, policyID, + isEditingComment, }: SuggestionProps, ref: ForwardedRef, ) { @@ -188,12 +192,14 @@ function Suggestions( // eslint-disable-next-line react/jsx-props-no-spreading {...baseProps} /> - + {!isEditingComment && ( + + )} ); } diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 00f820bc57b9..a3c8880367d0 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -574,6 +574,7 @@ function ReportActionItemMessageEdit( value={draft} selection={selection} setSelection={setSelection} + isEditingComment /> From d5c3bbdcd143e00eb13e851457b3949c931e479e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 7 Feb 2025 17:17:30 -0800 Subject: [PATCH 23/29] minor fix to command suggestion icon --- src/components/CommandSuggestions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx index 0ed1d317d0cc..ee5f55983a5b 100644 --- a/src/components/CommandSuggestions.tsx +++ b/src/components/CommandSuggestions.tsx @@ -56,7 +56,7 @@ function CommandSuggestions({commands, onSelect, value, highlightedCommandIndex Date: Fri, 7 Feb 2025 17:22:46 -0800 Subject: [PATCH 24/29] Change the color of summarize to green --- src/components/CommandSuggestions.tsx | 2 +- src/styles/utils/index.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx index 0ed1d317d0cc..14921316d88f 100644 --- a/src/components/CommandSuggestions.tsx +++ b/src/components/CommandSuggestions.tsx @@ -65,7 +65,7 @@ function CommandSuggestions({commands, onSelect, value, highlightedCommandIndex {styledTextArray.map(({text, isColored}) => ( {text} diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index bbfa1e5515be..b1f06458569f 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1401,6 +1401,11 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ */ getColoredBackgroundStyle: (isColored: boolean): StyleProp => ({backgroundColor: isColored ? theme.mentionBG : undefined}), + /** + * Select the correct color for text. + */ + getCommandColoredBackgroundStyle: (isColored: boolean): StyleProp => ({backgroundColor: isColored ? theme.ourMentionBG : undefined}), + /** * Returns link styles based on whether the link is disabled or not */ From 2230ba11233bb57b169f2096403429d5bac5a9c5 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 17:32:13 -0800 Subject: [PATCH 25/29] Add regex to identify commands in comments before removing leading command --- src/CONST.ts | 2 ++ src/pages/home/report/comment/TextCommentFragment.tsx | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/CONST.ts b/src/CONST.ts index 3aceec40f7ff..ad9e2ef1e909 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3267,6 +3267,8 @@ const CONST = { return new RegExp(this.EMOJIS, this.EMOJIS.flags.concat('g')); }, + STARTS_WITH_COMMAND: /^\s*\/summarize<\/command>/, + MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/, ROUTES: { VALIDATE_LOGIN: /\/v($|(\/\/*))/, diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index e13edbc210db..84aaf673f1e9 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -89,7 +89,10 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so htmlWithTag = `${htmlWithTag}`; } - htmlWithTag = removeLeadingCommand(htmlWithTag); + const startWithCommand = CONST.REGEX.STARTS_WITH_COMMAND.test(html ?? ''); + if (startWithCommand) { + htmlWithTag = removeLeadingCommand(htmlWithTag); + } return ( Date: Fri, 7 Feb 2025 17:42:34 -0800 Subject: [PATCH 26/29] Fixes to command suggestions --- src/components/CommandSuggestions.tsx | 19 ++++--------------- src/styles/index.ts | 6 ++++++ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx index ee5f55983a5b..d9463d20b6bf 100644 --- a/src/components/CommandSuggestions.tsx +++ b/src/components/CommandSuggestions.tsx @@ -60,7 +60,7 @@ function CommandSuggestions({commands, onSelect, value, highlightedCommandIndex /> {styledTextArray.map(({text, isColored}) => ( ))} - {translate(item.descriptionKey)} + {translate(item.descriptionKey)} ); }, - [ - value, - styles.autoCompleteCommandSuggestionContainer, - styles.opacitySemiTransparent, - styles.emojiCommandSuggestionsText, - styles.commandSuggestions, - styles.activeItemBadge, - styles.borderColorFocus, - theme.iconSuccessFill, - translate, - StyleUtils, - ], + [value, styles, theme, translate, StyleUtils], ); return ( diff --git a/src/styles/index.ts b/src/styles/index.ts index 9cd7d29a1025..261f88980205 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -322,6 +322,12 @@ const styles = (theme: ThemeColors) => ...spacing.pl3, ...spacing.pr2, }, + actionCommandSuggestionsText: { + fontSize: variables.fontSizeMedium, + ...wordBreak.breakWord, + ...spacing.pl3, + ...spacing.pr2, + }, mentionSuggestionsAvatarContainer: { width: 24, From d5e6d2e09356ea810ad3c782e876aac6a7d5ef54 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 7 Feb 2025 17:43:49 -0800 Subject: [PATCH 27/29] Fix clicking on disabled commands --- .../home/report/ReportActionCompose/SuggestionCommand.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx index ca17fa4892a7..008c1506d880 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx @@ -52,6 +52,11 @@ function SuggestionCommand( */ const insertSelectedCommand = useCallback( (commandIndex: number) => { + const isCommandDisabled = suggestionValues.suggestedCommands.at(commandIndex)?.disabled ?? true; + if (isCommandDisabled) { + return; + } + const commandObj = commandIndex !== -1 ? suggestionValues.suggestedCommands.at(commandIndex) : undefined; const commandCode = commandObj?.command; const trailingCommentText = value.slice(selection.end); @@ -96,11 +101,10 @@ function SuggestionCommand( const triggerHotkeyActions = useCallback( (e: KeyboardEvent) => { const suggestionsExist = suggestionValues.suggestedCommands.length > 0; - const isCommandDisabled = suggestionValues.suggestedCommands.at(highlightedCommandIndex)?.disabled ?? true; if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { e.preventDefault(); - if (suggestionValues.suggestedCommands.length > 0 && !isCommandDisabled) { + if (suggestionValues.suggestedCommands.length > 0) { insertSelectedCommand(highlightedCommandIndex); } return true; From c1a1b03d6d67e86e523513772c69d85cbf40a726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 7 Feb 2025 17:46:12 -0800 Subject: [PATCH 28/29] pie chart icon --- assets/images/pie-chart.svg | 7 +++++++ src/CONST.ts | 2 +- src/components/Icon/Expensicons.ts | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 assets/images/pie-chart.svg diff --git a/assets/images/pie-chart.svg b/assets/images/pie-chart.svg new file mode 100644 index 000000000000..57304c6ef70b --- /dev/null +++ b/assets/images/pie-chart.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/CONST.ts b/src/CONST.ts index ad9e2ef1e909..96f162a1c927 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -358,7 +358,7 @@ const COMPOSER_COMMANDS: ComposerCommand[] = [ { command: '/insight', action: 'insight', - icon: Expensicons.Table, + icon: Expensicons.PieChart, descriptionKey: 'composer.commands.insight', disabled: true, }, diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index b71c9e2402c5..062ef87bd876 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -212,6 +212,7 @@ import Workspace from '@assets/images/workspace-default-avatar.svg'; import Wrench from '@assets/images/wrench.svg'; import Clear from '@assets/images/x-circle.svg'; import Zoom from '@assets/images/zoom.svg'; +import PieChart from '@assets/images/pie-chart.svg'; export { ActiveRoomAvatar, @@ -428,4 +429,5 @@ export { Train, boltSlash, MagnifyingGlassSpyMouthClosed, + PieChart, }; From b0f1305823eee5abe7483b9089641ec02c9d728d Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Tue, 11 Feb 2025 11:47:32 +0100 Subject: [PATCH 29/29] Add new font and make the loading state more awesome --- assets/css/fonts.css | 7 ++ assets/fonts/web/Font-Revelation-Regular.woff | Bin 0 -> 35948 bytes .../fonts/web/Font-Revelation-Regular.woff2 | Bin 0 -> 33284 bytes src/CONST.ts | 1 + src/libs/actions/Report.ts | 2 +- .../report/comment/TextCommentFragment.tsx | 65 ++++++++++++------ src/pages/settings/Preferences/test.tsx | 43 ++++++++++++ .../fontFamily/multiFontFamily/index.ts | 5 ++ .../fontFamily/singleFontFamily/index.ts | 6 ++ .../utils/FontUtils/fontFamily/types.ts | 3 +- 10 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 assets/fonts/web/Font-Revelation-Regular.woff create mode 100644 assets/fonts/web/Font-Revelation-Regular.woff2 create mode 100755 src/pages/settings/Preferences/test.tsx diff --git a/assets/css/fonts.css b/assets/css/fonts.css index 7d24eb353189..53cc1af8e218 100644 --- a/assets/css/fonts.css +++ b/assets/css/fonts.css @@ -68,6 +68,13 @@ src: url('/fonts/ExpensifyNewKansas-MediumItalic.woff2') format('woff2'), url('/fonts/ExpensifyNewKansas-MediumItalic.woff') format('woff'); } +@font-face { + font-family: Revelation Regular; + font-weight: 500; + font-style: normal; + src: url('/fonts/Font-Revelation-Regular.woff2') format('woff2'), url('/fonts/Font-Revelation-Regular.woff') format('woff'); +} + @font-face { font-family: Windows Segoe UI Emoji; src: url('/fonts/seguiemj.ttf'); diff --git a/assets/fonts/web/Font-Revelation-Regular.woff b/assets/fonts/web/Font-Revelation-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..a1555cfb6054b48a4a1835599fa752bf9c7663b9 GIT binary patch literal 35948 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

    PJmS~x7P=+E_>DQ$GJHP+#FKzY{ zOjHv!R!=omGY!-rHCIa=P%rgX3w2Z{byYWYRu}cx0RF;Dyv!@S%4@vN8@$O|+NgtS zr9OH=FY1sEYl|jmsupRY#w$})I~?JHqHJUVj-$Md zqUVujTx33-v72Sz=I?gsclY-@Q|oXa;V#G_&*#4WUwp4+y}uLhSe*+hqeLYsSt&|Y zn$lHP<*=KfNA;K<*AseDPw8nrgUs7_^7$#h=8xv;9X`YUN0q2*=Bk^9X_}UqhnsYV z?$aZB%A91YP`}nodP|?^D;?K)hl_KGuAHmp>bYjFo$KOyxdCpN8|S9DS#E(_=9as) zZnN9z_Pg8MUG6^jkbBHM<(_i~T(--1#qQ_s*Y5Z3&+ZlXhI`L_;=Xb}xRdU@yBb6V z2|;3z9#jfy1oeU@L93uc&<&^dYGQ1s#>T$tNx)`xP)nS$b$}=h)=+gZp>Ed5*7m#rpB8 ztIA5@dA{aV>nEt5swkBg_=eZ4Uqba&RcXA)x4drsl4_u8O6LII@rLzFsiCT?EC>0X zH?3b4mYmZjov{x&MVjLGa#$f`+bBW`o6S&L?(}`T+r0FECa>{fv*EnrDMR3M+ zs>E5-X>y!1ovwiMIM!&Uah>KFH)w%zqh=BKN=ISmP3nGcMC4;|-c@ z%+M5LL<^0>HO)9e({XIl44g)4wsDl^7)NWaag65UxJ_G)w`-g6E^Rm7tsTZp?L-Vh zw7Yf|N0fi#!Z>VA|DG+$o!muf9P*=}(6QKzLn^Nt;GfE<93VuJpZ~e-5*+b7%^@J0 zA`-Z$fMm5%2P*kDb#?VsKWb{RBGlF{?W2*hl}&RTrD#V-ptH3ot2&5i?=aSTiD$00 z6f&))vW88h`S(X9@4SltMpW}IYFJidXoR!oci|l+;7FrA9l{EZuGRv&TZ^Kf&5LH1 zRjA5BYc*MBtri(%Qkyl_>aosReKuI-23GT48goC7(Auxwk!)+d$+gx8``*ykyXfzC zI@IrU3?hbcHvcc?^B5PAzjrSgrTw}+%yTB9o6n-FdFw}i1~8Z*3}pl(8O3PEFxFoPKk{Plf$2ltq_`+0x|d5DM2VfYUAU!7wruL`QDN~)|Xs;X+Lt{Q5iwpRZ} z-K3j!i*D6z=EYI|@6+<<5%}|n_Nm7D^GNdNk?a#n@d>5+gwp(}l;ckBrh-qXvQMb4 zPpFX{2l4#^XudD8|3pF>2J!o>w=pME&u=k009610084= BGP?i( literal 0 HcmV?d00001 diff --git a/assets/fonts/web/Font-Revelation-Regular.woff2 b/assets/fonts/web/Font-Revelation-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7050b41eaa3e3fd1978a9cf696131151ec4ae541 GIT binary patch literal 33284 zcmV(_K-9l?Pew9NR8&s@0D=Sn3;+NC0Mt|f0D-aq0RR9100000000000000000000 z0000Df|6w#C>!4t9F`ykU;u^+0X7081C4kD1_g**2Ot}M(^qIV_RNXQuF`D?5&xPR zdM95@j`MCuXK{I0ItocgtCs!$|9^V25n}?@9l)!fZ)LT$wh0Wny9x-i+FU49W+;}x zEpl0PkJi+BkFynR&;jD4L|>D?T--7YP3M8r*HW@Ze*&&MKZv2o&l zhlE^LCl079v_laWKp+JIf=Q5FQGHvfszD+_!v`%2n!%z)(iPy6W#ILAhdiZGL27Ww zBH`2!7^?;8G^pStDaSl*F>2B#b%Ng?8>ids_h-y9t312Et{a4q(aR+^zN*xg)%86h z3>SV3Mz{EHyITio;(m51f3kHIc9*ca1Ya6vVtTh1*f#7%qkDP&|M&afT>G4RQE!C& zB7^LbNT2pfz&v!XV$n2onphPZqB(0;vYUjsl0br3uowh_dkP8WrKr^?tsixzN}+aA zJ+YeIORq}nXHBo149;nvD?&?((1{DD#_$rQ$XN$y;hJsJz04E{jAUy$tuR0j`jvWP zSxBTIe$M_M{_aWm!&kWc-=5Q%VefifF)3!jBm@bbPzohbMJ<>l1oI$4tO7NlNNwAt zD}HUlJM6tw(S_BsFB-%M@=*b6W!LN@$p9)HBV&vr^=HA{rd{psXIe+r&PpV8La1aK z{`aKL*Rp5@dnfv!_cukb)LI>Sk+5-$jY+ei+oqND=0cN2BDX3R5h4lFK0%n04#h zjg{TBo!y=%oSgnIcVC$bL&kb9SC#hfp*zyPj+gYBYP$(1%o` zBPPfOR8-6aBiR(%6fq-4Ol>Qutq7=?NhX?*l1P#&MM)wU(DTasZSQ}~y}m4_-)|># zhmuGTp_M_4OxS7GJr!SI0B^m{GB~FU6YYC#V4e%P5xfAxa7;0zZ5UW)(9`pFza96T z<$urX^#6I_ob$$aMl)m0)-bvgUF-z6hQT(nJLM5KjQb-Yr8yO+p%@p^I;7Z6sl^pS zEiy<3MfR`;Guwc8q zAjhILmOGi$$(}!d{*36Met8jFI5t4R?kS@pcKstc5GzKLHP!0^XPoSNv-R&;KF802w?m7W<5XcP#CHY7?5wbUM zqj*`n7n^d6`eIR(DOFeJqHFK}=1N^aS>0Es0HwJ3FtfR2k|O~7CwRmKbkzhF*otbm zuWD^w{jX(BclAZZJ>p763{jo1B#+P-bJN=w{=MxBxvxoS8S)T9Do@tnk}xbO1WBF4 z2o%o2Kfhm?dN@LT_ncfSGa)PumsK(IsjO+Kj4^@N`I34~`H$q-cO8Wd_A=*D(^stO zq-679%vBWTm5tk9TTC}@Tr<$F9-ign%mXi|t%IX9U2FU~c4Cz)85~1B;m%3j)7Hzbb0J5)N9deu1p2Wwdgiv!ki!6aNp0S z{gOWmYA6?Xva|_Ixrao?BBY?rh`Z!cLKTg)(aQ+abim=!@ImM@P9u^hpH_TkK_bLW znqKy)NJA9q&sgR&3Ku&`LNuU;W@M<*!!c$9frLdwogqu|k#wIJ_dqSZ+6=DI0m-D7 zZQy`{3EKAZ<4TK0c2LfuoQI9sD+|G-Tb^bMS`mG(i~pMw&1i!OY4v1 zmiswI&08ij%KfOHi7YZjGdnchM4F&gP$!#%TIMaF5pVIdgR#QeB`n;5lHv<@y%sdm zsj@AgjiN5-z_%H4c%l96JJixY3eD7k$Zi1zj47@lioI)_#7Vlb>2UcJL(FzYhPjeD zH-|=cqVpjnq;tT!=TD{U7TZP~_x3x^()?MJvns}orvAmYXu zcuFh;e!?d9O&~&R&!B_A*|?C-+&4Nyft|o+p-e7v4iZzEz_n8^UdF#29$;AF$_lV+d zFYx?h{`P{4Gg;p&6WIG&U*{hXqA1M(Y{9kMFOy+xt^1*m?G>eG@9~z%E?C*9$bz z45B-lJbIw<{~9@e?QdxOZR4883@pLsdM|yozC|zAU(vtN^YxH+8){FTX%J1M9J-HQ zr4Q&fc_5gZ#9jrxwTn^)Apt1P5e8S@Wj&&XP(;#i8D%3S~8UBkl_NO z$IY_Tce_=Uo~krz)?p8=ZhJ>N-A+f9>US~Q9xJauxB0+vvsY}m<+4}4rgd*%QyUB) zJ9)<3#X~dmy-5Q`gpp>*8J>(_Mg%Nj4<3Rbga{I3q8K%3K_>>G!~~{Mj2bkc8EyE` zB-FG4GsH|YrA#%`gXzo67XBhqB#LyAElR}D!qio-C`Xl_b=D**y|z|&hx}>|9^;=D zPCI`yJ}I>$^R&K^;}P6`r7sDLwnr6j9Uejq8G zTnlVU!E^Z2#X2|6sRs?r3-E8cR3kI%-Y7^N^Gsdt-@2L2XkDVfv! z0d>KcLu%X=u)+Sbn-0-LBd3fEo|mht$cP@86~vDk|9{x6E>cIt)4(z>4@Zk^-}Iry-Yr^JXS|paVp|HibH{tU_H>m*-H6GxlBE?JaZEY+jr-PD*NJ22urv7&;y z95ac7YpVx<@0UYXhbxZBw_)BRKP48#$)p3`aQ<;%`L%p%0&XC=(@;Bj;A;Q|(kOoT zvj+OSqR@wAq5|(m!Z-vA*z)XvG(M9-610WV6ejM_a|6E8;|79b^h|b<0_})vr7IGG zsTpA7LR_Rk{PYUx3f5v@4tZ z2&^wzSpx=&V21{DSU-GleruS{b}nKKGuSFQw2XgRrbd3Ht2g#|`3Z%g!7VXub;bF4 z1?V*&L9lOv7HOKx0u{Q@%@(-oA0@^y_AIT9Xzc5#Z5Wc`Z*EaZX>CaX^j%@`uE>!^ zgp?wW4+&363QWOU3Mjvl=*mv|Cq>-|G7c4VbPdt1UVSH~%=I!rhmTQ7tup0sQ$ipz zXrW&`FA=}W?P#BwPppp%EkZ39sq$3su5;#~d1slzyrx;dMzZtjUV4ri;>X!qS}*Ka zOvitfceV^N2ipbiUXfvL*uh<}unKm|z(uO{SB3YY%KQ~w6wh8@Prc6ft?*zrcqK>k zvGd`VBb}EOJ=GKQG|yo_a&UjxuW@F#<`-+Vd9#mt_~oNH)@MBIs~ujOftF|z)kh9Y zZ5_Z++r^VR2;wf&+8J z`Pia}P}=bh!3FXvd6C@4Ha9#^MHRO<^e_inBRt$A!rcNQ>w9kDED2>qyskK`wP&90 znm*2gff=qBR>(q@ADO&vkvPQ9oKC4-Kq#vdg0pX`)()Qk5;^O}Af1{XMY{QjV(rEF z!6Ir_Rdn+peW0iH{-nBOH;AVAbj^>++8);8KO|ITVyn=LUh5lb?6eDJ=5kjihsS#` z&sddmqFO8Jsn?G_NG$%Vp|Y```J+x46ex-cHr6By%6DO+b2)$cDrz_e^?us{COFGi z#*A?_Vp+^?nDS3~W#3hTu^+#WnV6f8aX&b9Ezd#a?150`6CMKGsb()>U2#f1?p;)W zB4@Ip9N|_cv~t+B8IFgYds+zyaK`g-Nz+YOK)ucMZ*qfXx(c1*F}Q!Pyv({E=~{*Y zSEyIX4nzA*;x7G^&1?zS*`j_>6a4%~V(SEJ4||fb1Bbu8fg7$3bn zt#LbBKn2`oX|WMPw8@K_?htP0NHZ0GJ=2H`@{@I-f0R*muNqliqRuB|rzX;4yxh)> z_ZxURR@Xl|j8aCZC*4&&lk_t#Gbz}{^%QnZW5`r#z$3dR#C7=*Webl@Ay*l9d*{%# z#TbtHJiBum3Q{xg1zTnyUNN0p8kFa4Ltn0n(HV2UF=KNb(`re~irJ1t zUj{11#$IkEd7hrL(cwg@H;C4QW~c}5@btF-2{9;ewOi)M+jlje!pu*T7TO$1TQvS8 z1y2p2i16WiV?s9>$vA@bmy_t>+7(+cuKY6oSQ zSuX||Up8GMy2jJ+;u}=ItBYR0n2^>P-&_c+>93h>(Vlo0pMggY9liuejM6u>pD6T4bE{C>8d&Ou@I4U*| z6Ok*Kw@`8#&lg@K*9Z_eqmPQOk+#lQ!01TcjLCdO<1l&O?iw z!fnIwmt=lCYP0v9(AXIT$gPc2Za#b*W>ty$ZcyE^CMp&-NjPr|u?@~m)4504s2mH= z%zFJ`8dRxt>WCDb9hN%D&p8(7#AYyu8~FT$+=%N`2NHR-d#FFo*R^T?^fl2A*?gL` z`B&i5564eGY`ineZJ-=&y;%aY-A29XR87su;3{J@FYoRW>%hgYy&RTmi-c&67$n&V zv1w7RI+0cd4X$BhrkZ^lIguqM@_#Of+kg?p%H&;LQ8GSX7*)Q=gsEv$@_o>9MOy|cXVr|P_ za$a#J8qf^`!p2uIHoSZgfF`C9U|ABKD-5Nl`q>`5AANL{B9(k2tbLY70@u;GZ9QSe zw6RdRB*`(*f_XgJbN(aFC!TZiI&8%H{Kb6Y&R=Cgi!VJkyQs$nukw36P|zCJyP26+ z6kT0)5zg~mTh_OvW9N9lI1NrKm-dtTL3+8RO)Nzuw}PYH#kEYgGBpvQPuE%;BwA~l zCT@=+^H^&TAMe(hOA;^@AO5j5a9tY5MxMn6r)rZxbB46Xx8yS(SWm+$2$&`u7Oy#3;q?N1i zqwU|#UK=CAbPiVg9pu45ukv9j?eeR5OVBOBd3U3`sbr1VL59@hGXX3NS7?qk`!?=d z)69;)C^+W<&!ggFax#xetMUsp@=*7UA`qtdTd9$hV>k zcPa9JX{ke7^y1CiHEBKCEmj+TsfoX|7nw%@X?o9mqa_^3in-w=t&rDQ>lz?A%clg% zf~B&T)|d}7v;(LvQ&1CB@pr0OwAk+~rmx=B8%c|5FxW5P+)P`=#hR?PK2D{bNiRY% zW}x#Ts(I9|y?6iWvjI(l(FURGib#FcTkF;|_Ud)I97D9=(_+E`f{nNfi!n6LJK=dH zYn>AP=#^KSxqNf_r1JM;JHKs4N3>VhXT9L$v_mIdCqbp%yi=5h+u8G5#qRu1IjkVw zlWMqX+nimoW&}m2@5gtH;JoA^#;AfqJ+sNotoJj ziYvabMYBH-enxl~F>4_nkPKEMxkiy`yrpi%tL@W<>Hxg~mM^xEfM;Y5ED-5jZ46mHLU&>-OVR=zTmL{Z2ARD)GN zi)iwY5{LQmWXbISmrs=#DMee1*-g=YR3!P0U)dXb$}vKKwBE|}$@^;QF5g)*>dE{ldpiyEL0L=bX{m*#T0r(BC64$O}jevnlTiaZIMAkm>ij+tcg&XgrzD=8NTOy*a<__J`y5 z*X4ST-T%jl@n}d=49n31uZXgw>5dI8)pQLnh{7cHQvA3Y_Eq2e`7ds-?{2OxfqMu-58{nlV;oS;NW;8{+QEmPGD-F7_J55g!*lej3WI&Yht2}<`ac}=)rSpP$vG?+S;9D-IuEMT5uijqQ zT~FQYy~(~=bMxJ8+3gp1`|gtNj@;YbAA9)A!{Ucu9zS{fV{wD^U(=O#t=Z4{IJVff z9N9PQ;hb&uiX(9gTq8Hdadx7d8&18mMAp1ma-=&{K@BuW6SPE4q@{Ke&uIJ%U(VO^ zb$l~FQ+ml5c~h3j8CfM8)o?+c@=<;&Ri&vBHH7cNN&jc_f0Osl-s`=0)813`B-)J* zrQ7=XFemnn|NrIt{P&&QcV*wzeWClp_C>(Isn^1Cvq@+TibuJcA(}B77aV|};=dHx z`q%ZF2RMIn9Zo{Sb)D;qL}B6w_q3bi=DAPZ2Dj7w*!Dj!>WbP+ZBRFR4c?Ha@?Lmv zyso69+m7T^vOV>0_smp6DlL_fnofP|Xwj6Wu5s1a)8!coBlGQFg@4sF^rxY#zkX#A zxc}Jx6Z=o?KfC|@{)_uB?XR^UuX_%B@t!mM+M&OXuo;m*(sHEt;q8a-9QkR~!D|R_@2vA3J@l*y_73$$Ii}&*P_#CmwIH`G?K)6G12X{0F9iIn391 z^D;$7vWNC%5B97Lx=Z)tF8Vh9%cuJ9!%_H!tsyDYh5*js1`mjX1W1C5@Fsi!8E^-N zz`z9jDmG2@+%${taUs|9V}8rsEaZSf(v9-eAVo;; z6w{&UnD~r>5U=JUTVAurEgs8Kq(!G0Lp;|^M!8B|K#pQaDbrP~aU+}gJ1FV4-ynuCZ zoz~37$f8fkLhsPFs%|#F6>h^z`1RKR0Qn$K?-O~cUfQ)C+fD3l+P!0!YUfS=vk!9k z+@bE!aBz0;cZhYk<&fxb&f%KFO$T2Fm!V~BTbX}luy6+BuNds3yGam#1i%X!XCZ!a zC)Nz>_Tjx3qGS?@wn!2)&+~#A?;7hfN|+YbalBVYRz<1`q96ic|9}FiN7O3b``0^? zp|5Dmg`j%rppM?XpZDQg$Q2hN^tL^n`7Qa1z2`XRBMPF+LJ7|I5_nvGa&A%zJ)2nPmj7Cv-bq&c=Iip zWPlax(S%*aDV#w7>{S&I97k;R6&Gm;$N;xyb1fPG zsj%5@vWA3WcAkl>h^*ST_Z9XB{=60qxL<6u-$~rU!YXtH6x^;=g9jkokcI}@q(@lokf%tqgarT*H% z_BK3P+9JH>&71P!d4<)O6L^RCfSKR;Uxd#qYibnCcTLwqB1FDn-gw~v z!#kYg>;mZtm97NwOy(yp-CN+4Vn;Y>1JWS$$t8z0d$)1khrh25M={dMRbI_k8(6W} z9mX#RWSE&U{mWS;bACKp5dUo_{&0l48CmY{Ml&VLge7F!%EvrWmNPYF;n{O+H-!v3JF)HjN4SRn zbwX`TB?|jAC6(1!)Eu*Eeuz$CnFI;vs4dU`sO6l*BN`&1NwO~A{mt>9+qkyVsuSOx zM!pe8z&fQe|CDp`l zOn0*9g~5(KpY6fCojUKJ?35IA(ST4W28((k5ySeT7Z?tay+NIXpUK=am%5O6mVf*6 zqCQ99u>v<85o6~Azu%x z_Ooy5iQma3pH63nDBw z|H`%XEL`%@G7pNclbD_Jx-!yqXKq|xJZg{c9y1$i>6&W;_gBp-wLtEoeC?yX9cil? z*wD@kHS#{MI2&SBvPc_Q3dokm>XWFG z-|P1+iD${%!&6w_JbhbN#IUbuQzZF_CD?h;Ny$GV=Ipc|kvh!ZiO=*RY8e~%*)UUw zy#|KvzP($hbNF!O7%E>9VrwiMpM4PByE$w2b2G)i{J4K%@{K8=*|j8O<|G$ZUW}5- zGS`+2slH*_ll|-+eFIzqae;^u?<|(`9FMw39y3 zvn0WyN8GGWn^QdpUZvvk2Pmd|_{N;jOn4k=K;jShxF``lBs<^b#mrC2fu^F3J4EUe zKEjH^or&+yqVa6X0RI>NbBB#my8f6~fDw}&+_^A&PdVO%&!td2ey#h#{AUqJ-WS9j zGlR!6R-hMdQhXE3gI+K}qL7z!x9t$WbFfOUQtUUusz+oqO!J??22Qd5?lE|rH`RZs z%Y_uv0WW3{nuk9l%y2mrd$1!?C)&wW&Ea3f@jlX}g7sYHR^(Bex_9X#CI^e~x zK1FL#VcpUdWM(Was1AX3hNqEtpN}N|Ot;V1;g(HQL*#tV76WlN0iJC-_t&p>49_hG zGjQkOlk#NyOB&wnwS{%dxjZf9ieXA@h|HWJ?)py^mFfkQ5kzAanEjC**TpVSai+z< zdu2;~HmE5U4S)1B3ObP;>MDmmwa>nM!4Q82c)i4go)87QW!}e4<*H#ZTfs1$yK89ml7cVONdvfahz zps8gwQ4#MszoAFShRx#A$Eu$(SfO7exvn(dgOr(uO@c#xoDfb@kvlUbnNSv)S;M`e zH;&r5q8(Z5j5o&M3?t+7>A`>7NrsKAHUwB(NA_Cgsj{s#7B)$a`$_6H5oNbd(|`IZQ(lw_^gvR+G63A2wSYHDnr|aC2i}DU#_ByadF8pqd(U#trRUu!OC-U( zIsu^LR-LaEJl!^8fnqpWwBbf@{miG+*@?CRboB9rAEO}m|I-GpLbc8l`%fS9a-(?) zo7gb~QsA9cknTb@SE-?afnK(Z3dJ8pGiiF zSGLG>NiYY#!^6=#zle6h(<8(`%$TU;xn8JaWXapn8C*w(Ql$h+k7+@dVN6n^4%guuZEZ&l$lN1GuJOG%dd<-H=oC3G_#4W~;P9HSGkYZYHMkHJqzy#~ z@ZglzUuwxW1t@}{pyog6?-c5CRwtcZS(L>T!BKIAHii40^`ie=MS+)I`Jr}*Ln z>CW%9*{ScV&?Ay3>aKP5O)+KBHZ1w2{v>;z)scQXMR^5Dm``w2`0(ATlHF@ypgi^- z5wBwezh?G5QO%(}AO&y$DSxBIj7N zDDr1S%Ino4&W)LP=-BqCXH~&^((1gXkPfN2)bIULm?Bv)j@x5Tii2n4Jhe&Pp zqs^xSw{Y7B_Q7}QdceP4`d$Nf)e<-I9BGRribAjWS%QXhcBK^FDox)Tl*%PAXBsQ8 z$X(4#h;>Q4k(-x<`2I^uo^rds_FfaiMn;H;xdZ7P21p4jgelyYLl!J0o}A9Ar9{Q% zFw9VPkP_+ek&HZxp9*>-9_>$fXZsGCUf%2c8YY@{Cp^%R@r{s=4^+uoyD|KcBZ?Mq zdDr+Q%}ed2108dCl(fM$-7lE7PrFJ;&!PC*NOLxdwSMsd&a_S0{9iv^Zv3#?!?v(x zez!x|(kmj&WV_&!+$L)(Ss; zFZq{6T0j7vC9O8qj3qN^gX!tfo{s@qu+<*iaz?VqJT~$Nl(?G;5d&b*Yp?>ami((TjZj6(r3xdTm0W< z2FdA3*Fz9bN|$8i2r=Jlgl_v?sVt+K;qkw$nn7A`pCLoI{EAZ zu6uLnmOczwa2H+R2uKfB6Y6|LxMO#0AMQ$b;nZPI~ zvC?k?v^OWS^A&{W-w*xWti;&Vx3YQypDUkg)sw8#j6X- zl`hX%h=FsWzm=(LT=y%lpLfje5DWWI*=S#VeP1PtucAX5T4Q^9AJMO;sXjBG=6>cd z()v@WrSi@>Cb3uSEl3b~B;sY)DVucyo)tYl-H;${$@1r|Xo`GPINJ|!1yB&J2wPMR z!lEZ;Y=2hL){aiB4rylS6he+GM7&3i@APKIuY2@qvt_%FBFXkg)?dUexiQLiyp~N7 z!}jo^+?jQ#c) z>oGH@Om) z7%oA^8yRq%=#Y39Jn&M~JnU(iWpbWIxd%oEI`eRU3&R<Qh8q-<-05LB0-Sk&iG|tOwDBX@nzsqK;_EHjB>f z##NiB?68p*71NYGi={Fn*YwmpC&m3CS?5YkW5%5(eD)djTWnlT0z)p@HYbL@o&8U_ zBOF65sk8P$qdWEObQlbOgz4yP+TKIl{zN>Pd#<`Br;2@Da|-p~ zfH{P=y9$ryS zVTeDN%y>@8OI?tUrJ~ZCf%vLPE^)h#lcx{Z8`b($wK#W2aLJ5<6%os9K4c`xX4(-C zsmn2@_#cy7Vnnjw>Ok$rA?k5woO;1wy+v`&Gh+NR_6v0S=~c&>dxAY8+9aTO@^(vM zbAH3#jyOwvVN7xKI`zQcCKv7~LSOZ}Q=3~*kL#5ul5X5fVi@Gb>TM&C4t*RldQ5`H zN++vF9uY%A?Y)KJEe7NkOA2CZvN=f~i^?rb!VSt}RV{bxnTFf~D1kgQH``wj82Z2NeZ{rVz@-J5}$)EV$@>TcI($D)`c0NKQQBuWCjcwnBkHF{&Dho2EK0 zom@3GO|@}%CGmTb**lMbN+{w~BX_;IQVl|kh)z;L>lC`O=FD8H&z-3D;THBf~k zu5l^~!Ei`3l}yE{zTty_nx7HIx7X-`5-Iy+f-?6v_X!0yLCdg?}N)AxZSeT;5MYvU2alI zlR}PCl~iHw!-ZK>Q0G;UjODX(^2VRYNy@#3S_7!#^_gWg^vg8WtBoXoQ=TZm(+Bx@ zQ!9oRR^v)9KK)&5~H zGg=NXGVR$(qKAT{JXY=pSa~0yMXcyj53yD6${taZB3VU^jBNvi-3qy;{QB8`<0m9% z;R{}YzUZQx02MItIY7iY>WO{5er3H5+(M4^yh10UseEnQqb|tj0~~hEh8rdvZ=0B@ z#=jVwq0SfJ`gXk_7z8x6eTlM~)V|f2n%@Y;mKs7hhND-5hA0*z@7p29UHYWQpgg76 z;T=l1ff$&pzNQ@xd4gMi1BB;1rY%Boec0&YmMI7UzemEZ$z!O0z9~O_QMLUt+0W3> z&-<|1TEkw>=d(ATwej&fG|X+fV;>}5$LXA3pa9~qr-?U zhHyb#!mY0I`to+IC-4t^%rv#7+HKchm+K5bBMeCPRLZ$?@Xp4*oIR5q4*?5xxD6kb zT?LSo(=CX<0g&}2e-`@>gdWxC@`A!)mQ0BsJ!hu9HM>9u4XievHiElW@vv}nn%77KhLjeScoyDf%UY10ndr{HSn~)HCp4-3@69q za3tzW7QWJd5f*W->RTkonf$YT(WU&wlU?ci^Aij|HPpq{qcu8uvp0G%4#%(wf8pLD303JP|a> z@HH+~;Y^h#I4y{3#H9uWa&d|9E1s>QTWbiNf3@L~3sdS&Rz*fLI>WgTotIuB{C(!D zgna3f^Q&F*yyC)^y=OQDDMfY z_bBEyj)hHHI+N^t|bBQRb4pLPy*wX&@_uh zN_@PuzDZUox;$##Rd-Ls;Mg7&{wk@((n`$)qSZ9W;`9YtLdaznxNPGD4rCfH;5E=Q zz!Weqo(6i@Y;h+wa@LjAENZ%5yf*8I(WBQX`Y*nryoLjIlNyS$X|Sr??Ap*7hUFgE z5SN1nIVy$t5lQa?bgJ@0y}s3^A8zm2@lSIDVY{?i-H$4X`}Ch1TNfZVb_UQ=mu=Sn`J#z)~4w}8{f5DSt#^AOdG)Hxle z#C5g1zYM-1)H>dW+u#SCM1@DXLrSe*UD|W#*C`JPoYdmOn%&PRNwD4EFnPJjv)cmP z9f(iAm|w5_{7Kt<*v}WQ?%^lWZ32U?-qNB3WK-|S*7ZM-OjQ%E`aTA5vTR=v3pT)8 zck2=28!1a3?YQ09=M7gBgcl01g-a}a49nCcWNx8)tc4T>4W6h8ad3tjQ>ZlaZXG_e zO8u6YmJ`L0+tyP=DZ+uK6w$K@Ca3R?iC0p&=!YBP7GlZE-%%wY#Y1Zh+Zq6cIhy2G z`=*2BV(?Dy(qkYl!a`HLtU`?#f|jm>^a#ewI{ICExx(KZ(gFbFv*l&LW?RMzyIx$K zM!lMEG{90!vLsnh&>nCgDr7@S7JIYfX&PMSk89Alw=Q8Lc|VuZ?*iNw?$8RWQo~ z>PA!Q6^TuyI2NTRwuFo`caUes6wTf!?VuK$q%*+LTY9LF#Vbv*t27kAmKOIUl=ki_ z9c+l7dY&e;>CU+7;Bp%SePkw_zx{%h-nW9%vW%bV&L3UpQ`Y&EZ-Z|IeU@EP`dCoU zvPtamp6s_K&WU5SanGCgBMz$2<&K;OoNvT_u7~UapBTX>MsnPmE$sq}Lr*%U59!Lwq^eT^C>J+v?Y<4>;x9nS90j7$L1@jFnNe`7rVL2Xt_~whJEk*MF0ChdL z86x>WAH((Jg8>{C!2;f30Rt?+|8m9t>z-nWGtY~4AT7o+drobXqtqJLEyhaV5%0MQ$*xP*b|0nz9Jh6gd>%cqy{8ory`etDElVYShw-_4q#= z+-N{Js$A{?U!ndIhd(nIN}!VlB{??I0Bm8b(d*ow;=C;PcevXJ^P%G^uIc~*$JpdEaw}bo7Np9e* zu6NN;&BtiG-M{+u#b|V8=J1TAYRvWTh|GXptjWX!T!>sEvsVSziAZrfJlYLH)JNmQ z89hKl?gPg=kl3(@kolXmh4m1-1Mk3w`v7cEnc7Z3dy{ww;F8{PXWB;DGM@@eFL`C- zr&@d;-15h!kv%#=4%fC|RiAE807-H27Xj)uDn7G0qY0hmTnaCbs|_XR?ez)3qqbCe zja-@?lM{=AI03CUn#G;$dA&Y?@R&{NZ^KI9_O?T2>0`=L+KB53#we0`baZBAy(7W( z)b@4so$rz?`e}52vmx33fW(i$eqZ~GW~k$(R4-fSE7^27T=n?YI!~mIZZifwjHobA zP@nG9J9SHvk<4sJlJ;SIeXLwwmGxT5{dc3oA{tTrt>Ot5wO4=N{Bd4RA!1?VbUhI8 z0#z899Pi;5CKm&Cx&ewE>!Dcf(<~F+abm8%oDIUh$pYxPjNpha9V_21HxmlZVjyZc zb23aP+DGJM&TN6vt3E^T2fxONh3zJ#Mgp)04DM}`+#Ur=G(z2VuT2bpvj`OGu8W?Vndl><~wuK#6 z*v$A-xMH%?YnoaML#OfB{j8Iole##lWJrGti0T(V z1l2-7a&r!{M zml1OxUVdFu*zqDg;L0zU#V^=jX>b`JMG5`Co$(F6>Q(4#$)rDIH^&b3?iAsxSdp@$ zuYE+BdP7_q>7x$m^2|@7hl7pLt0GO}=2(Fj`vr-m7x-}3u3gE&X1>M;_z%JguKkIm*DFLj9>+sLq`KX?QN0{aE*EQ}SSYdG zCgKZMwp~HeMB?{YKb~T5Oaas_SRP^y@ZEO3L*O8+?Q1^s2^wFo>2O|%Zm8LA$Yh8f#h$|c@^+6NQVL2x`Zg5BFZxFN^!07TxwE|m?7*oPuBAZeE5pt zkeNS7Gj}(MS5lei(!g2k$0Y4;&`3}aUvd!SZVUsic}C!n1Y^iP+kJNnfp z`8X?ZWwv3xpXKl;JVL%n)?_ZIfd{ivvs00S`?f@3U;Owfe9f=-%3lp*c6i=LME2IM z=b@rDSgdnvnef`5e3NQkXc8(z$f5%crLD3wHg2zkAJt_Q_GozW8!sUq3CRid}Ws@)e6)~IE^-|zLka9p=m@!(fwo@$31*ryt5k01lGjA z4??FSh>z`4GdlD3mEZIvLoer38q|fSCOfT|4W=xbpcL)8S~Mv6Udb+fgWj;G(P2=v zVO+Agv__Z`lZbC83M=>w&j~PqXm*U1OPg!()*5+o7h^cpY>5T)Af6k*nz@5^LzrF1 ziHRJC*Z-pP4!Mc_v>G|cL5+^HP^b_wB7r!LAtvp!#|==OA(7O7_H%H+YO4A2N8S>a z!>k*P|EkIFn;AXw-4DMiom~W8M@B?>&uCZ##3!)erk2)hRXb?cHQ@~b6Y#8vldh;A zuEIq!Yo5e#tcc{+{+N_#EUgPaL@j@o$5emBW4|9b?S{ zDQ|TAKYkVkU-brCO@$hb+=!;`ct4_H_diLjdBYme;4Cxu1(ESQ=H7w1qwATt@pp=s zo%GF?MU61LY{p1)eQ#HEEf34`Y4dn9=b5aovpcTdAM@vyOQNEEvEv2md0z5Q-=znd z`||l7x6q_i-tlg}J3YfAdFPcIGeMA8g z9K1rUE|iOUnF9*N=xC(O2RobBM2I5Nab`giX%<+qip`TT`r_M~4ylrHrf!b$E+)@) zJ7}%*tKna9Gk(>R+FlognGG-mQ0qJv@X5%xNhFP%u>>Am>fZFqo$mwQDEVe(c*#wQ zHwU!c5;gcTYrxlTtUUHIEBUvbtW{u@5$qN5bIz@btI%{9 zdJcj|C%Vq^tDMLBk5;{eUBm8uOC!zl`<2+)=;To;l&k25y!iL;3)kBDiDF%nkfPe}q=cRj^(SSj<@8ncLXmgG}hTXlOKE;}l0bz{6EBmDdn z5lYOC!2NnBL1l+@7)K%!I4q2D0w=QgqY?0lNWJbTA^0?r98qA8OK*D&%9j*7ob^)u zL$^Wvw`BFuM`B<#`1D!d?;c*Fc#t>U*Oi>MYKW@9x?u50|wKW*?-rx;(Ml5@a zH-75UUaJX`i@DV$c~^b-=z~|Lz~)u*{i8`NeEy>P7GHzQOFgap({H!(PFec!cz(VI zB+SX0PSuG>s$al;p#YD=yiSZ`td*O!v1O17srpa6ePJYj)>Q$~UPB3m)p61BLGT_v zIy=M1+ev~v_%7K*f8avvq3?ZDwcx^Bv0Qls3=-ax^BN|<)s2r@cz*Khb3gV4oCQ4a zFf8N6JjhbF_CVu!YA1Lj9a+1tgZkvqtj`85w4iRZW(VTO4Zu66XnlVZebD#}-AR5f z-n1D%`Czq;EfMQ&{zgbNn|NLQ?SZ-158U{v$HX|lS~IC0v59B-B{z6>+2WuXUN;c? z_}8!y@=veBzn1j&J(!tPpWl7M-OG#E;qTjV$!_ZPmM$N&$xQeve6sBo*J9TnfRxjR zl7KxG8Tz(TBG7ppMgx)BIzXntP!qxgjEROJId7_K8dO`gq5sb0t9<(v`YMtCC>WE z?nG5G5mCztQKM)Ezp8`)qLFPPk$9%z0Zp3@(KG%#=z?*43K6yn!d$NV!t2jD>KLRR zq|zjPqULQ^2rGUT;N@$-Q1cgSZNbs699D^GzEEp%M>(;!oE8(W0is`r{M?Rt<+@tI z9x9AyK8g-yRZvb02RA33>uhR8 zbPJ3rs(Wh5xQSt}hIl?W(oJq=YK46vS=5~It>%v5vEvr zDKiSf<16ub-~O{UzyrIbq4)RvG~zKc`_do{J3a!iB>WM6*5l;NaTg!;He>dkkK`o1 zX=rc8bcd4`h?FjhRP2web+v2Ip!hBNPbp&lpO~$3w`6xl;j@vUIB+;SH7BW$v<)Xq zGo_8AV2qbD8^w@^*z*lp*mDpQ3~+!A1oc@RNN28rs<5${byE=1DoA;sYMMO>N7^sL z%S3-8BM;y6p|B&@``pC&?uR#mXqH<@68v}-2?h8q^C=VmA8@(4zN_FYtqwRl^*>6)qYhGoBR^OXipeb1UglPzeskOaigOCgWlsv_qWa|8g+N$qL*;_m zHkW2`p8GJDS*Y*YA_urW-p2XX+y=JE_k9YC>oY{k2_s3&(i3)&gi2!*K zH7EIXtXzj)2S#i-61oZFAcPn|?;PN4vVxjVRawuSf7_Z@@TN0!E{pELldhXNUe*bL zOfTwQa2%Y3zk={pWv^y@xhn3WmQTf&V{8)ObSxD1q&r`+THsoz9e>o*s%;+zQc)z}@1L1}r?Xv1ZQimr zwv0&)jFCcV8xMtRv=`RlEh<9a_Imcg0ub}v3Ge4?eQ>M~`@y9|WnA83(~2)5y1mXI zzs7M^$kj6?zCQaCV6*gT(CNuAQsI?C!re{NNY10&e}d>RLUF3wf*H5j?}!hY;$2qm z8=yRe+iuN_y7#x;F7Z8m#fJ><$bJr?dz4Qjz2TroE^ZsI%`d#NGO$KYyo=;aNQGfcP{ z(X&9;Bo41l=CX93YpIGFc&EU{nxk=ByT3JUyU^{3Muv~8KZ2E2KI0b>ZvYhE0uUuX z8l`XgCD1uDIX6OSq*H)p3!PcTso##-_FJEVB$xgN*a5aer;juL6g$Z=joX+A?7K6* zNE0W{hr$be>t=*`;~Epefn5W8QsHTs9wTzU->!E8oPrsj4S}f*;DXiMST`Czm$=Nh z@gijaPWB1PT#2W=t(Z;cA94QN6w}D2fJKIESD-xj;X-g|03BkJ2g@@n>kb(0>BL$S zVZ2&J@?<%r9clx7hU)91^UKTNtN=ISKKQb;v(urCAbktIyWb;7U=LocN9A5PLE%{u z;WVvOj0|0P5`=I;qOI}`ctW)Oz#)NV(FYPPwF$Em;Jc(<;9j>Zm&(>5ufeGSu05WaaOM*zLGwzL+ljHJxIK0Ih3(;jtBBBw<+X07rz9Y0k>}wqr25jRF1c?h;poyM-1-g zQ5q^oUovT51#a(vp&%}QDLH!_Koz}$ArBr8f_T8=K@ch)svK#4NtbE-Zm6;`01s6V zs-7qghHmnB5QK`?uS}@2AcpV{AG#Wl@XvISj$!tLX*`CahDmglS937V_^iYgK zjpwt|NfHyAcP|3kTw^qUgYhevnmQPwt!+zG_^_beQC(d!S)CoScil(#6O08+OUVgokUDV= z;93|59K-aP3CTjqn40(<5Q*<&e18JX?n^XOY@4Cs$n}WO<&4ujMG}Rch$KiGSwPP1 z6Fj#L+d3NGvjP(9AWQAZa|r@kT;}Egnm|>Z8iO>x`xY=cnutgcRf&&_ANJCkry>6N zVkD5+rKElhwR#+-f>;s?5;MN8Q~aB=)5&VkYw$Kk)Ru@g#bswYLp0ed;A9Xw&$I-M z1e*<-U3@xyop~iuLXZn1m0*mg&mj%_s6g@pGg{VFt>yKDkPHot{YR}nv?(bu4O zxNKUr?ky=lLLDB*0=cf+trFYn6#I4fpd<|tZ%e%29r*E$DUE3pX{i;qubf@n#a1lM zB5$-w`B*$g5UZ$_Kli#>S%liLh{b}lw$zqvi1Zm$n+t9<18oU59|kx^&<-|$<$0zy zkk+D7ew|_U)WMaGHPw;ngc1n;NA3Au7st4}9@Y?o&5|7!10c=Dm7;|6B38(Aifhp% znnh@;DJ{^X4AYiX9RA69Nlr`i2szV_Xe$8Jr;35>!M$J0Qk#8)8d1#>OMx&ns?^Q) zVuuA;r5l-u-7TLWFJY9FThha6Ns2gaXgmLRtnp8lP*V*0B~4M|vV6EWONw|)m1cWz zaxr{2xDd5xCdY2R-pz_f5~wZy}cc z1#fY3aur)b51eI|7kz*Ao2$8bs0~b>EP@OtD5g18?!JwAq8-1WagK`bSO18dcL4@e z`N3YVY9l71QUsY-MA&Yv=kA6a@AN6`zWI{dNYu|E(V+9tQ(DdoRk!SV{|0z}WpWR+ zT??La$!V){F;gR}+X1x?MGs?6%@weU}up91M!Y7MQ- zP&i`jFQnhPCp*ao^0Zq3{5L-`5fizsX;E7Dk(LEG*e-a3_A)1s_F00d^~f2pW=^5$ zp!aFkf!oWm)Z7Tr3Lsnz?|o42bN(Wxt*gjpIW3hJat?Y@_7wqr4}bakdg=H~?0}z4 z3o(ltvVVu*4!eHGL%Tpf?h^rT-lOs>u2JHlY=%72cdJ+F4Hp##7X@K+7Y-l04RXI; z$U~Rm={53MVfZN^;J*MUa|?)H67f(BieZBNY|&c=(dQ7ugB&h9$1#bV{Qb|LKp0j{ zCkGLARZ>`02NxEK!EYn{*GUuLwJEy7XF>_}D7x0lz{sl31R2qZTx)zQSi zKG_#_a)MvLaSid%C*||;SOR~=zx;21fYE;IQWs$x>A^T|v}B%b2gtl52~+HM%!-?z z9PljK-_M;>I`Dk4kuaGh!G@bJc}@ENEhIWI_SPr0CJ#Rkt$^KMm(~Ld`yD(3?>z!E zwkyPTMAO9Oj(lsz`hFbjfcJhbStdTmB?NKhDKL2;B~F`Lq8l=ge@{ z3pAkSagMH05W2FYU;}D0XJ1X=8pYrAuaS-#P_(ZE232OHoSNe^BH?M`ShesfAAh?? zVRJ;+UtM1S`*1@=k#|DJ%aT|-UGjuQQpx=n@QPE;p8^rzsM-uY*p-69r3;ye-7TLW z4`Gy)KhFZECsO!nL+kl}Y`qTVx~PvAXG-C3sfz3coP4aj8-)1nnZj|Huea;8;lM&D zS?lVN9U}#7HLC_g=y*+fDA4B)_(Mb8TkAw5vaTo}@7*KN(612YsLii-Ne4y2;Pe_9 zic{ZH03W^_3DVrWG=fxAPz;J00YWj@fxa%3>AO*Qp=*d65{`NcL}Ez`i42E!Zq>aK zt}82XNP~Oz_jBefuA_Gz$8a@;N^&wR7E6*P6pB?>m?RZeV)+DmZ|MQx)B-Gr?9+&h zM4#oL6DF8pZLfrj9<%xcR!O!E#z|bmZ;;GXN#z#?I;B;BFwnM;5I7Bt0s_zp+j|7^ zPsR>*p$06OXE$>QGeF8Ncl-g-=(BsB2_&h-g-4e$sG)94R5zMPSbt_Vvm)+L@qo)n z!QS>GFy3HPyu$4E35nz8b(~wIpZ1d!-EfRpv;?BNQ0Ugib%}$$u+AbB9cf-7!XnNE zP(AO`iT|vG!|dF!#FfSg0p|gM>o8jj&|5>ND({6}iOg^@(j#Rp<4Qx0%^%@i_3n1cY zyf(#&d9;N&j^?2)ToO5@N#GplI||S@0pMr0E%b_}1CSpM1_HknwW^ z#FeO+Sv(a?-PC_jix$R5eAICSj!X&o@T(;%Jp~nX=l_R)7}igF=@UX0Ll zSR3tu)H8#Jrpw{m2Spe>RL9fxUAIwbjky<9p)V#!c{ zrAdKJPRtcy64ayRa+b95hz*`aPTBccQMHtW^E9Zd1i-eW3W> z`RXh7sNV}C#f5vWdU8?yTh@r467jl0p+ACjAF;DK!$A}4;l_m@&FI;Un|L;i>;~JV z>0g$Y%vO$3^IguYIX!CIqYzXiBLzZ`V1x-`cHtDuif@>S?MSUcbd>@0=f$ZD$p74Q zBYl^KJXnbwxC}pAd|p0}cM`4AjrZMXC^9wr3UxXG(Y9hwE-^ zQvWXNtd3Zy7xbH?@W`ayc@oF-c%+gY;`_pkorO}DS14|V_rUI4cosl!54Bb|PA{dV zt39<>CigshM&Q+TN3I5+lb;8%3&lpy8)xlzI-%Dt{Zp%Sqdht;G)&8B+OC)OY?J8B z^Xm7qZth6_QswuB3YBo=jj;m8?z9KT*eVpe4jYlHUstS`I=-vc8R^ec z<+7*k)vi2FFd9EL)*zFx(;qjWr?_UltPtpVi7JM>#Ypuf zm6s{)QJc|o{dgnM)?Fmj(xdiS`gG+8NFZjt7(96T6(aib+9rIGpFs|dTjOPG%U5n~ z#*Qv7#(G{fW7z+|h%k8Io*nnDis^vy{a2>%;RxHeI1g?5(@IAR@v6A`O2sf;w4vWb zj>zR8<<=yw?Vxg%Wm!22xm|jv><(eA+mn!3DGH)_-L5;ysW;G({PeuEJS|33v&M}cGV3Uy6AOm^ z!DN;T9%XIuK5$MR*TneZH|S3A?RGz^tx<6rb=tNzWgZ32St<&^HlRNv!E95SXOQyvt+;5Z%#>HWPskeEdqIwzZjh3oD;(x&*`8J@ z*DT`956Z#m|6$aq>NKhh`5%AR)#3lpga3<9DN*;iVfc~6bigB!?VbDdY5skO!4KcZ z`+k2sYPP}uz^JSO39@jVJRNI)g%M@cE5gSkDYSgkr#~u~+t=o2Ck#%;cblRr4vWAIA=eAN6 zs?|vqqNYKy8O61lmRm^`HP+EUs_sW5WfqqAYm|zo^=ZMTN^~JCqpD|W!>(iVtx{&M zW6n90`4i@i`Xc>1TB*^+7O3W`SjAoAuQU2hq3G?6Py_`KKyOy(q zZ*Irx#XIOPjjzKlA#!T<3*B!#$h~ zja05L|LU(&G~sC{!hM~Yo8ucgZ{lnTHM4Xqbi)K(zDl@|tekI^`pM%LhqsLkE80}p zz#lI?!fy$2iBCAocnmfiv(6jDqCE$Z#-_IIzY~cOP9E)zKY+KwanwOnsW1C2Kt0P|5~N-jbAx;jB&dj{$?9 zzIpZIUZQdDhkn~P;rU*V39IEZKU3F^H2(&!8)n8p;INMtVN75%bDMv;R^@qSzhLz| z)XH*jQxOr7g*<1UXH zPeR78oOGF(?-!lm!HbkBGx5^flo1V78F>H?bU7=-^J7Y8e&`Fi0BE2tEa3&*fheyn z8SH`^RI@vd(~{MkRbWrf6SKd1v60CJl;dmdU4SfH4s6|u27Y+9vueqpK?!!&7G3Tg znWKB%CRm8EEW58fFAh@`U46?7G2yPr8SfSvW{Dnh?%?pm=Y3($w!s2(Gyndjdw9VS ziq{W)ZN-M*CZn%TPAkF?;Z8M~Bs39Zk2|0GXGXf0WpRL8mzdA;#*os(R$Px3deS$} z-SNx0f@hDBQT~yG%J}JzRrEVV>H#pxNUf95z6&896n^NTq=n`eviT!#5WY=~B`Hx~ zi((nmq`29~_zM#j8?f`)A?1wUQjerchYtB8r8KWsYrdzXN8agGMsBkPL8>8dWGM$Z zb>+(JoO?Y>^rgSY92drWw%S&5a%DlTnB^*~%x<9&IV;>_C03R+%hXyS-wi!t%dxZl zBimxE11P)o#O!18h?XXs0wRxS7w@m!^IC(IgK~LiPjqv*DE-de+vug?j-H`}qfIbKxN5!ufjn~JJ&qAQ&rEt%gEPvg#W&*OgKPpf zr<#1Jp^MkKC$Rqzm0I4|(91X@?)P0B?C0=l!Xu*4?a$c>?g|Jq%z0#XPeV&jL-yG5 z0Af!<0)0?0cAW6Noff*!3BnMTKd+`)T9+$EzF|Bs&HhTXU}Ei%}$yI^J}zu9`N!;QL5j85YhQL4urWcSSH>W824{t}}eNYdCq* zgs86r0?R0ugN6K=_=@pd92G|Y2|%t7+U(9DZXAhKmq86}DTD^58|+PlFz>ZlZah_W z;HN{3yHoAvujZSZF;^2kQfSQOG4LA$WSa?qNn9%3XzawN1f zDp=>>K}u{!;s=N>%gZqG{yeAA=lbI^B09YdjCf zU=2FQL8ZnJH|rucTDxf%xjj&ll6p(~ii;dAs{5K0cn7OOQF@AL0gcy7&uZTd&-`97 z5-u$+4dOJFI=%~nl}hLjh6Tkx>P#|@Hs@4kH)|c&hH&cpb9V+vnI0cVKIIEPy8SB3 zULd5`Ig=oiC|_WmFPaCngN}#a{d^nrn(^DBUOkxDcqXDa@<&9@w2z~R9#&I6qJ=%9 ziYTIb&9VPNHg@Np)l+fVWtNVt$p#$t+grtxgcH94!9^H!0*6mLc_*w!B088)XPfk7 zeU9VipXOnM2_43FB~fC!{7c2Je?t^%L%>}U77LY~SaIabUSSbe)Lvvu=wyS7CUJ{{s_Jn@JE#B)}a!|Mh z1F6Z_UyWQ}(1(<2g-%1s(DhH68XLl2N^r^DT(VkoIcKNr2?XEP;)9zM?VCJ@#mXK< zGkuiv?c%j&#QHCCxi0i$QmjVWBNSVWLkF#7{H`6OluOYVsrbQqjr>Xui~02!Cg+)~ zvg|Shg^y$fEXI_Z>qF*K?eEzNwkuU6(BqbnW=kol)2AkXct2*n84bC|qucE$6mF0q z!Y?ilR?f&?U%fWeqJB8pocG3>Hz*pj?cX!c#!*VF#MZlj-HHJ_#g7aXXUSQRG4P`B zi(p$K3LhSfhgr_Tw9J_zk{SFIKXaAzlWf&JWSb2ou_*Wc)zI_CsrAZ+D0%LHF5;KV~hp_ zSTo9tIfG(4El}P-nSfeBIH&YP#uxOq0ir6zngaSyX2-{oBB>C|Jung&j)`kZ1{oS` z{ckS0HQ|n(xnI0MS0D7*0V^bQ;x!tv#Zh1R9rHN-LVF7NEB6Ajpf8a`^xH7K_(i3- z2C_%tXDDB5yXqIbS}br#7EP)A9>qV4zqbq9H956G(6#@D%Ia7T=!<48s-KCCA1LY1TQ`xH-_wL*6)WB znk5-n>cQ1{9uT?6WvIv)!{%_!gndlLN?t@XDiF6t>%yM!((0T9zL+7+;?KJjaOX{& z#NB%5JbSF^(dAjUbVI?w9IzT>qH-dv^zW7Q1=0Q2?fkeXdkVrC)V34wCbpRqhkO0| zU8v%kpI^%Vn7E}6z)E-$QN1XKOL-c?C0^V@I>_Q{Im7rKV9yHbH8iOqvffAbcw`#r zEzw3d@C3o6*nzZor|E=Y{F2|Y?xAt_uH1Q#?!89G*BdX+3hP{0_qN*u_p9x)>O&y* zu}xFEnO76%vd~v@tOz{le$I|oZp&Yg12eS6gER7|)kk(@o)SB1rq z6u7kGeQckE0$$Zx;e!9Y51@y%PUtq)RZ#y)u~Nz8O><=;Yxzw$<756NwTcc)F(_g=!ftb8f(;Qupe9pFC=Kh0$wI?kP57D6Xryj zzRW_CG75R^NWqnP(fQ1Uz#yb}sh&xX0yNjcrXUL0sU>8lF#3*3CA0-t zy-0AtT5}X$Xo)UFS2Ne5>s8O-m1GoCTSW^z?vltjL7C&X3KA(6_MA$cPEnFd^fL4+ z^xA-Gy8&5D4q=vd>a?6wUT&*lT2=`~C8og8a%g3Wd_^t(R4OWMtPF^zfMP3%r;4~$pGXmKU}VT{nqI3yCc+`X zsc%>{n9H>l656@}uyTt8D6-kjX;y$kNX0%WW>x{#3#A%S85xrhN4Z$4-3yZe)+y`d zio>9znzA)t9R9ft3MdWMIr9;7H)}C1v7}p_f^1fH^iqhS5i{#k7yB&V4R%UGQcJUfSMFzm1Y1f ze-)i?!9R1^{=Ny?{v{pr8^>!TDB%0gVXYeq?EZT@hereK)p^us?ahL_mI1q9ZS`aD zZC9{;VawN6+-n{;w?|*|u(-Zfx?l^;=uJ-2?SiqTfZ_1yJ#3q8Xc?|k%WbI3-J3w7 z@-0!$({JLhe~dB6cv)+&QVj`CZ#SF*k#$e*)Ry3NJU z(oDabExbU1&EJa=pksUQ7WqLzh8*fJ*ukm|l(lN)w(@(OoxUGW6xI8+;q3XxVMEq} ze%a(++t^={cZxP<8`>PmZ)Wd)t&2J<_AUbAo4dW)WUMKb0kS!o;VP+BH2qRi?`dQr zzHUmj{%$t+4GL`h_m?S(ZW}I)E8A+x`=CZ@C{uk<$`h9Msk&&$QoD)yRmyH->uov*o(ep$bO^>Lj>hsXnus zb)%F!u8zCZEyZJy^m*iGzxdT}e)p$@N7dT;10z8E&M$JdS(u1ZjF^iFze~DI*~*lw zRINs%F{`b!-liksn}7r+D5iwPlBk%(H1SD@IEj&fk>x7|{PbJ7_|Z?SNB&lOQ)<>h z_(VW8aEK1ngBc;8A&yGOaEmt!7!3gza9Cwpx%7ZuHcW zp$7%;rBdLdLwqmgj39o0ETj|;SRsXK5(u?Jqz7UvYb3*#BOOQPk%2d+K@K!+hkeKE zgje)@%NzoqT)Yq@keb$}aSZi2c6>^6wI5FWvM4Ce`qXHcdg zJqPmw+Dl-seDSDYj9^4{NN5(+ieX?8o+Yq~f$=A3twUf7NpKbgwmJim2}Cv!Wx$jZ zW>qi?jWBKr&mOkPn=$pa2if9)-~?W|@R)KT2}`ocLc)?LF~OWCB!h%(YLNNguprEF z(8fWC83)^jW%y#qS`0ZcMA>u&^ff$_h9VION3dv~R;ixDAT6a*D%|#d@R)oQiAXqt z-PDi7MdW4TuKh}tl(d3l*h*8!nGCFU;rBe@gEN0U;>I7~84p@kCC3qIjsdFHOPYvk zS(O||q&bR|MC-8FZAOE%m7KZ6w~qd=KuzgEWi?3FgIF^tYX^nNAm|2}eo!}^dYoc5 zVm@>+aXEf9cs+Wvx>N6`IE*+BJxx51zYM;PzD@s_=7}kv9Qo9+rzd-6!m|U0QjsV#dT8cXX!s0MU`oH^ouhevn)bMpG_Y3*RI4QR~LE9YHGVpS;Q)ZdLMCy&#vgPH(r_3^g ziPRgfQ+Hlo1B{b$yA!mn!x6=pc>xIEir;(A{`Sm!?@4&1C>{~*J4``vPy&o1-s$z6 z0fL2;KylDKi~uV|BDOoXSN@=4b}k@SNaac(SBZ)>&?+(=upYU=(uzJGboijRg9G+! zs$rbGuqTpIv_*tvc;rL{k_ZOno;4nYSaVfkm`n~ZhyJ(Vc3+EQX(!M7>*T$5###Fx zh24$wtk#9qxp?DwneVaZd6hM|zD75a=56h!PFNWUw^y&E!_*95Saw_Q>F7Q=duDnN z#pMR5@JQ4SVUseM4wdV;PJEgkL9uLBVVC*Zb9{T|U0V!um>MR*CRSb47Go6;_}tiO zd4k+n!r3IZfTFi?iGX1kZ0s6Yp21GsBu6(G1Hpd)E(#q-1=xth4m90m>eJ?KqLqu2!SNWKNLkH8VrX(iD6m*K@QU4Apj|NkEpv9e61nC zG1sVYuJ}bs6JPP}&EBXgN}AQXs$O9~nUmagrpd=g5bKrxP+mUd^rvHz8aMs;KaOAO z-9H!?(i9RPD25Z{L7adwJszvzb|$Q?6?mT7xCSqmj2XOg z%U1BgMQy>yNpYfG!^(g@N3{*^bL_c<&}SMB!VS3g>?-u0?mP& z+{YX0aNyM4oVmT?aWHu7k5P(L1PFt0hzOA&GUNhLASy&Vwbz#)JmzO0ovcrjCY_3! z8+RI79z1#RcAC}5|{sED0Iu@XIc_31ZYaID;EzeJv!ytEHB(K9kgk}gA-s$|G)C@0x+S0a=Sr2PKrId*`0dzxrxW2-Yf;X5}{xF&cs0K(QQDlVJU`O>#cWE z8Dt_iH8ZbFwOZx!KMr7BdaQe&Rm?wrc42oLpPgN={7>$N{ax0S99_QoC1 zP#rfzeb;l&j7X>ua+cVRq`C!DsGsIFeqw6>&`iDLWdZbScdi0xp?>l zs@UZT*V=n`ASu3IL5%})K6*FWw$->)Vq8r z0MCXQ6HGDf{HI^|qL;Yz<)r6~EqBE$U)8zG2BLiJ>t6qcH@3-L+Bf@Q zS9f#w_h3)=YQNTp`lq1?8DSi}Lvpwt+QatSnOXc)?EO8|+jO!A|EHoY@LOGi5}E_|^0TrOdAWsZJUt z3e72FvWSDTL}I9XAH|MC6qu2;$mTC;G|S(zF$(YQX6crppYgZ1WyDL!9*|-Of(Z~B0LeEz6I=2D1bZ$c>?kj(6~W0?8Jrw7zI3jdU}N11wz}KF)^#V?`tAm6%}9`B5dC`$pl&-CIHaEO*eg-2 zwGIsoO3VO)wjVMKnFzTCzRsXSf;BR8!y~VfPfoJm1mM0;k$nsStU?%*xb(v*ixZ!! z1}(X@NKW2OI#U1`b!WEjbD0=Aw!RfU{TN%xrj@8M`QV9o< z`H+gUA_8KK&OLy9J#P-D%R9hedHcAa{af==K?LyUktL_j3XDvH*Uyq4^;N)H$WTk7 zGy+NRTMDiBFqaL&&;SegF^FK}(5>HI`yF)HF(;jMhqF~PKE{sLW9*oGuzWEUH;OkT zC?IIDuHero(yWn-ZUc>fsH3KQT1rz~D_#*H10Dj$hDKif#icyXR(7LV zbiVHMz?wP&4ZSVi3 z>nn8S>wa)$V0T%O!w}dFlVMtT3Dg$*fV&=Tr}mAusre@6_ENc=Gjf`oDCfvl47lDa zylu?sbJbMu_Au^4+^$K#kn@0>fZKqFJ_EdgkNcfJ4n~HGC`hk={o}l|ob<<6Ha>ek zWxVslP^k%5W4Rtn)T*{psWM~q!k8ot!56#1dK*Qklc`dcIjUq!mZRJ`mT?*lU8@RB za&5QC8Xaa!lxMDa=8N>X&s^|@^UfZUMPp|X;u59Pk|WEQT=@!59%)}9Pl2LZwc9HP zZ?na|+P6U$2F5iv6~F1`KVZL22YCOhtG?dHDdIJ4*mpDpG_c()?aTWg)^F_t5vMTV zX~?7FzYcrblny{$DfAc->UxCy3x;J)wx_Aqn{K%|YNVv*ozG@xJ^V&wr-Z>vo&FVIn@Nwt2fR(4`UHzo~iV@JPn-m^vFMQO~ff8kk7@2ojzq7 z7euusQ#e0e36+CzGjf!^ZbCrcJt1ME67a&Ib9Br019`Idk z#)_?Z3D2C*MF)3Vgj$(T@7=Z{PSb+PDRn;U)%?_mm6%Ohm*$^pe)YbRWOHlOvE6A$ z+ED?hGtu*HoPH5U2IBJ%vpM+%>(IKC7e;q)rrXz@vXUJMaK4^6bqUW?{Yd3)$ZWe* zJ7cDxxqbAU zw{L2SJ&LYHRxr+}lk&*&-&Is{nD+i``;ZeAWWt*(z9DJGg;l+PfvHQIg{nm(h96e*2U2mZ3q#b z;mD^oq+)FPb-K9f8?vdkC)>4e^__bLPcXKgJszlOEfNA78y{#TfoKU`uKl&Nav1kd zwlGah$F}A;DXX>}*i5d21?yLj%X37KpDoO~zGv7TeQ<2tsV z&6e3FJzHz3AC6I^Y*NX(8TX&a7nQg5mG_`)>@5IB`~PPa6SAs}g^xMawI{ll&tbAb z3Wn>hZyRjVUzgSg=XN)l7w5gQ-upI7NFDhi-{)CRGJoIk*0h}i0f+&2{_G+vfK`b) zV(Vl?T^2&R5Eooh+^A*7okV*)&{-9agf7LiG5lBNn0pQJJS=~-&-Xv;H30(ZBmm5W z2QfrncMPLtB1XjL+=1wNOi*4#Vggx+i4@xslQ8y0>_WZEF@eIg@p^QF?Y0AiLW^2%n%S^5lr8|{EV#~cr z+3U zF;SLXD-29Vwqe{eJb6e!dE}zhK1RfyEI%3t*~Fy>Q{4;Su~ypUo_CK&Lv4u*6CwK6 Y^?I;eJ2#4ESN5?Wog2%vPAdQa08|3{d;kCd literal 0 HcmV?d00001 diff --git a/src/CONST.ts b/src/CONST.ts index 96f162a1c927..cd52a68ebbf7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6687,6 +6687,7 @@ const CONST = { SKIPPABLE_COLLECTION_MEMBER_IDS: [String(DEFAULT_NUMBER_ID), '-1', 'undefined', 'null', 'NaN'] as string[], SETUP_SPECIALIST_LOGIN: 'Setup Specialist', COMPOSER_COMMANDS, + CONCIERGE_WOBLY_COMMENT: 'CBQWNYUEIOPASDFGHTJKLZXCVBRM', } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9c906d46443b..38bc6531ed08 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -734,7 +734,7 @@ function addActionComment(reportID: string, text: string, command: ComposerComma } requestCommentAction.created = DateUtils.getDBTimeWithSkew(nowDate); - const answerComment = buildOptimisticAddCommentReportAction('Analyzing...', undefined, CONST.ACCOUNT_ID.CONCIERGE, undefined, undefined, reportID); + const answerComment = buildOptimisticAddCommentReportAction(CONST.CONCIERGE_WOBLY_COMMENT, undefined, CONST.ACCOUNT_ID.CONCIERGE, undefined, undefined, reportID); const answerCommentAction: OptimisticAddCommentReportAction = answerComment.reportAction; if (answerCommentAction.originalMessage) { answerCommentAction.originalMessage.whisperedTo = [currentUserAccountID]; diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index 84aaf673f1e9..d92d34c8b794 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -13,6 +13,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as EmojiUtils from '@libs/EmojiUtils'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import ScrambleText from '@pages/settings/Preferences/test'; import variables from '@styles/variables'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; @@ -57,7 +58,7 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const message = isEmpty(iouMessage) ? text : iouMessage; + let message = isEmpty(iouMessage) ? text : iouMessage; const processedTextArray = useMemo(() => EmojiUtils.splitTextWithEmojis(message), [message]); @@ -103,13 +104,21 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so ); } - return ( - - - {processedTextArray.length !== 0 && !containsOnlyEmojis ? ( + console.log(`%%% message`, message); + + // eslint-disable-next-line react/no-unstable-nested-components + function MessageContent() { + if (message === CONST.CONCIERGE_WOBLY_COMMENT) { + return ( + + ); + } + + if (processedTextArray.length !== 0 && !containsOnlyEmojis) { + return ( - ) : ( - - {convertToLTR(message ?? '')} - - )} + ); + } + + return ( + + {convertToLTR(message ?? '')} + + ); + } + + return ( + + + {!!fragment?.isEdited && ( <> ; +}; + +function ScrambleText({text, style}: ScrambleTextProps) { + const [displayedText, setDisplayedText] = useState(''); + const styles = useThemeStyles(); + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + useEffect(() => { + const interval = setInterval(() => { + const scrambled = text + .split('') + // eslint-disable-next-line no-nested-ternary + .map(() => characters[Math.floor(Math.random() * characters.length)]) + .join(''); + + setDisplayedText(scrambled); + }, 250); + + return () => clearInterval(interval); + }, [text]); + + return ( + + {displayedText} + + ); +} + +export default ScrambleText; diff --git a/src/styles/utils/FontUtils/fontFamily/multiFontFamily/index.ts b/src/styles/utils/FontUtils/fontFamily/multiFontFamily/index.ts index 38a22e39b9df..9316ed3ca369 100644 --- a/src/styles/utils/FontUtils/fontFamily/multiFontFamily/index.ts +++ b/src/styles/utils/FontUtils/fontFamily/multiFontFamily/index.ts @@ -66,6 +66,11 @@ const fontFamily: FontFamilyStyles = { fontStyle: 'italic', fontWeight: fontWeight.medium, }, + EXP_REVELATION: { + fontFamily: 'Revelation Regular', + fontStyle: 'normal', + fontWeight: fontWeight.normal, + }, }; if (getOperatingSystem() === CONST.OS.WINDOWS) { diff --git a/src/styles/utils/FontUtils/fontFamily/singleFontFamily/index.ts b/src/styles/utils/FontUtils/fontFamily/singleFontFamily/index.ts index 496d1a32648f..d309d2e05fed 100644 --- a/src/styles/utils/FontUtils/fontFamily/singleFontFamily/index.ts +++ b/src/styles/utils/FontUtils/fontFamily/singleFontFamily/index.ts @@ -62,6 +62,12 @@ const fontFamily: FontFamilyStyles = { fontStyle: 'italic', fontWeight: fontWeight.medium, }, + + EXP_REVELATION: { + fontFamily: 'Revelation Regular', + fontStyle: 'normal', + fontWeight: fontWeight.normal, + }, }; export default fontFamily; diff --git a/src/styles/utils/FontUtils/fontFamily/types.ts b/src/styles/utils/FontUtils/fontFamily/types.ts index b3fcf2964804..82cdce81fab4 100644 --- a/src/styles/utils/FontUtils/fontFamily/types.ts +++ b/src/styles/utils/FontUtils/fontFamily/types.ts @@ -11,7 +11,8 @@ type FontFamilyKey = | 'EXP_NEUE_ITALIC' | 'EXP_NEUE_BOLD_ITALIC' | 'EXP_NEW_KANSAS_MEDIUM' - | 'EXP_NEW_KANSAS_MEDIUM_ITALIC'; + | 'EXP_NEW_KANSAS_MEDIUM_ITALIC' + | 'EXP_REVELATION'; type FontFamily = { fontFamily: string;