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 000000000000..a1555cfb6054 Binary files /dev/null and b/assets/fonts/web/Font-Revelation-Regular.woff differ diff --git a/assets/fonts/web/Font-Revelation-Regular.woff2 b/assets/fonts/web/Font-Revelation-Regular.woff2 new file mode 100644 index 000000000000..7050b41eaa3e Binary files /dev/null and b/assets/fonts/web/Font-Revelation-Regular.woff2 differ 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/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/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..e4c2f2af9ff9 --- /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..910b42b 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: 'removeCommandTags', ++ regex: /(\/summarize)<\/command>/gm, ++ replacement: (_extras, match) => { ++ return match.replace(/<\/?command>/g, ''); // Removes tags ++ }, ++ }, + ]; + /** + * 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 b8af68ddb934..cd52a68ebbf7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6,7 +6,10 @@ 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'; import BankAccount from './libs/models/BankAccount'; @@ -307,6 +310,60 @@ type OnboardingMessage = { type?: string; }; +type ComposerCommandAction = 'summarize' | 'export' | 'create' | 'insight'; + +type ComposerCommand = { + /** Name of the command */ + command: `/${ComposerCommandAction}`; + + /** Action identifier that will be sent to the server */ + action: ComposerCommandAction; + + /** 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; + + /** If the command is disabled */ + disabled: boolean; +}; + +const COMPOSER_COMMANDS: ComposerCommand[] = [ + { + command: '/summarize', + action: 'summarize', + 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.PieChart, + descriptionKey: 'composer.commands.insight', + disabled: true, + }, +]; + 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$)/; @@ -1724,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: 370, }, COMPOSER_MAX_HEIGHT: 125, CHAT_FOOTER_SECONDARY_ROW_HEIGHT: 15, @@ -3210,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($|(\/\/*))/, @@ -6627,6 +6686,8 @@ 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; @@ -6651,6 +6712,8 @@ export type { CancellationType, OnboardingInvite, OnboardingAccounting, + ComposerCommandAction, + ComposerCommand, }; export default CONST; diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx new file mode 100644 index 000000000000..966fcc771460 --- /dev/null +++ b/src/components/CommandSuggestions.tsx @@ -0,0 +1,102 @@ +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 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'; + +type CommandSuggestionsProps = { + /** The index of the highlighted command */ + highlightedCommandIndex?: number; + + /** Array of suggested commands */ + commands: ComposerCommand[]; + + /** Current composer value */ + value: string; + + /** 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: ComposerCommand, index: number): string => `${item.command}+${index}`; + +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: ComposerCommand): ReactElement => { + const styledTextArray = getStyledTextArray(item.command, value); + + return ( + + + + {styledTextArray.map(({text, isColored}) => ( + + {text} + + ))} + + {translate(item.descriptionKey)} + + + ); + }, + [value, styles, theme, translate, StyleUtils], + ); + + return ( + + ); +} + +CommandSuggestions.displayName = 'CommandSuggestions'; + +export default CommandSuggestions; 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..f74a5c1524f5 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CommandRenderer.tsx @@ -0,0 +1,30 @@ +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 Text from '@components/Text'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; + +function CommandRenderer({style}: CustomRendererProps) { + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + + const flattenStyle = StyleSheet.flatten(style as TextStyle); + const {color, ...styleWithoutColor} = flattenStyle; + + return ( + + + /summarize + + + ); +} + +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/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, }; 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 `The maximum comment length is ${formattedMaxLength} characters.`, 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', + }, }, baseUpdateAppModal: { updateApp: 'Update app', @@ -667,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 f2db2c5b49b8..c9e3ee5cdccf 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', @@ -545,6 +546,13 @@ 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', + summarizeExampleArgument: 'Todos los mensajes de hace 3 días', + export: 'Exportar informe de gastos', + create: 'Crear gasto', + insight: 'Conocer tus gastos', + }, }, baseUpdateAppModal: { updateApp: 'Actualizar app', @@ -659,6 +667,7 @@ const translations = { emoji: 'Emoji', collapse: 'Colapsar', expand: 'Expandir', + conciergeAI: 'Concierge AI', }, reportActionContextMenu: { copyToClipboard: 'Copiar al portapapeles', 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/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}; 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; diff --git a/src/libs/SelectionScraper/index.ts b/src/libs/SelectionScraper/index.ts index 060cbc613acf..6d555a144ab7 100644 --- a/src/libs/SelectionScraper/index.ts +++ b/src/libs/SelectionScraper/index.ts @@ -6,7 +6,7 @@ import {parseDocument} from 'htmlparser2'; import CONST from '@src/CONST'; import type GetCurrentSelection from './types'; -const markdownElements = ['h1', 'strong', 'em', 'del', 'blockquote', 'q', 'code', 'pre', 'a', 'br', 'li', 'ul', 'ol', 'b', 'i', 's', 'mention-user']; +const markdownElements = ['h1', 'strong', 'em', 'del', 'blockquote', 'q', 'code', 'pre', 'a', 'br', 'li', 'ul', 'ol', 'b', 'i', 's', 'mention-user', 'command']; const tagAttribute = 'data-testid'; /** diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index bc051f1472bb..38bc6531ed08 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,83 @@ function addComment(reportID: string, text: string) { addActions(reportID, text); } +/** Add an action comment to a report */ +function addActionComment(reportID: string, text: string, command: ComposerCommand) { + 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]; + } + requestCommentAction.created = DateUtils.getDBTimeWithSkew(nowDate); + + 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]; + } + answerCommentAction.created = DateUtils.getDBTimeWithSkew(nowDate + 100); + + const optimisticReportActions: OnyxCollection = {}; + optimisticReportActions[requestCommentAction.reportActionID] = requestCommentAction; + optimisticReportActions[answerCommentAction.reportActionID] = answerCommentAction; + + const parameters: AddActionCommandParams = { + reportID, + reportActionID: requestCommentAction.reportActionID, + answerReportActionID: answerCommentAction.reportActionID, + reportComment: requestComment.commentText, + actionType: command.action, + }; + + const optimisticData: OnyxUpdate[] = [ + { + 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, actionData]) => { + failureReportActions[actionKey] = { + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + ...(actionData as OptimisticAddCommentReportAction), + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), + }; + }); + + const failureData: OnyxUpdate[] = [ + { + 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 +4750,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/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 0a461bdf756a..f6f5ed6aeeb1 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'; @@ -53,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 = { @@ -61,7 +63,7 @@ type SuggestionsRef = { triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; - getSuggestions: () => Mention[] | Emoji[]; + getSuggestions: () => Mention[] | Emoji[] | ComposerCommand[]; getIsSuggestionsMenuVisible: () => boolean; }; @@ -532,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/pages/home/report/ReportActionCompose/SuggestionCommand.tsx b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx new file mode 100644 index 000000000000..008c1506d880 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SuggestionCommand.tsx @@ -0,0 +1,212 @@ +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 SuggestionsValue = { + suggestedCommands: ComposerCommand[]; + 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); + const {translate} = useLocalize(); + // 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 + */ + 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); + const commandExampleArgument = commandObj?.exampleArgument ? translate(commandObj.exampleArgument) : ''; + const restOfComment = trailingCommentText ? SuggestionsUtils.trimLeadingSpace(trailingCommentText) : commandExampleArgument; + + 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 + // a workaround to reset the keyboard natively. + resetKeyboardInput?.(); + + setSelection({ + start: (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, translate, 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], + ); + + /** + * 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 leftString = newValue.substring(0, selectionEnd); + + const nextState: SuggestionsValue = { + suggestedCommands: [], + shouldShowSuggestionMenu: false, + }; + const newSuggestedCommands = suggestCommands(leftString); + + 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); diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx index 8b7171340b63..367603762977 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'; @@ -43,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; }; /** @@ -62,11 +66,13 @@ function Suggestions( isComposerFocused, isGroupPolicyReport, policyID, + isEditingComment, }: SuggestionProps, ref: ForwardedRef, ) { const suggestionEmojiRef = useRef(null); const suggestionMentionRef = useRef(null); + const suggestionCommandRef = useRef(null); const {isDraggingOver} = useContext(DragAndDropContext); const prevIsDraggingOver = usePrevious(isDraggingOver); @@ -85,6 +91,13 @@ function Suggestions( } } + if (suggestionCommandRef.current?.getSuggestions) { + const commandSuggestions = suggestionCommandRef.current.getSuggestions(); + if (commandSuggestions.length > 0) { + return commandSuggestions; + } + } + return []; }, []); @@ -94,6 +107,7 @@ function Suggestions( const resetSuggestions = useCallback(() => { suggestionEmojiRef.current?.resetSuggestions(); suggestionMentionRef.current?.resetSuggestions(); + suggestionCommandRef.current?.resetSuggestions(); }, []); /** @@ -102,28 +116,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 +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 /> diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index e97e4c611922..6d47876b5ede 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) { + const isCommandInText = text === command.command || text.startsWith(`${command.command} `); + if (isCommandInText && !command.disabled) { + return Report.addActionComment(report.reportID, text, command); + } + } + Report.addComment(report.reportID, text); }, // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index f8ea9b56871f..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'; @@ -45,6 +46,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(); @@ -53,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]); @@ -67,6 +72,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,6 +90,11 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so htmlWithTag = `${htmlWithTag}`; } + const startWithCommand = CONST.REGEX.STARTS_WITH_COMMAND.test(html ?? ''); + if (startWithCommand) { + htmlWithTag = removeLeadingCommand(htmlWithTag); + } + 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/index.ts b/src/styles/index.ts index 7eaeaeff459a..261f88980205 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -305,6 +305,30 @@ const styles = (theme: ThemeColors) => 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, + }, + actionCommandSuggestionsText: { + fontSize: variables.fontSizeMedium, + ...wordBreak.breakWord, + ...spacing.pl3, + ...spacing.pr2, + }, + mentionSuggestionsAvatarContainer: { width: 24, height: 24, @@ -2338,6 +2362,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', 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; 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 */