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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,9 @@
"مثال",
"moveElemsAttrsToGroup",
"removeHiddenElems",
"moveGroupAttrsToElems"
"moveGroupAttrsToElems",
"mple",
"Selec"
],
"ignorePaths": [
"src/languages/de.ts",
Expand Down
3 changes: 3 additions & 0 deletions src/components/RenderHTML.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ function RenderHTML({html: htmlParam}: RenderHTMLProps) {
const html = useMemo(() => {
return (
Parser.replace(htmlParam, {shouldEscapeText: false, filterRules: ['emoji']})
// Escape brackets when pasting a link, since unescaped [] can break Markdown link syntax
.replace(/[/g, '[')
.replace(/]/g, ']')
// Remove double <emoji> tag if exists and keep the outermost tag (always the original tag).
.replace(/(<emoji[^>]*>)(?:<emoji[^>]*>)+/g, '$1')
.replace(/(<\/emoji[^>]*>)(?:<\/emoji[^>]*>)+/g, '$1')
Expand Down
24 changes: 19 additions & 5 deletions src/hooks/useHtmlPaste/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useCallback, useEffect, useRef} from 'react';
import {isStandaloneURL, toMarkdownLink} from '@libs/MarkdownLinkHelpers';
import Parser from '@libs/Parser';
import CONST from '@src/CONST';
import type UseHtmlPaste from './types';
Expand Down Expand Up @@ -101,15 +102,24 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive

/**
* Paste the plaintext content into Composer.
*
* @param {ClipboardEvent} event
* If the clipboard contains a single URL and there is selected text, wrap the selected text in a markdown link.
*/
const handlePastePlainText = useCallback(
(event: ClipboardEvent) => {
const plainText = event.clipboardData?.getData('text/plain');
if (plainText) {
paste(plainText);
const clipboardText = event.clipboardData?.getData('text/plain')?.trim();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We shouldn’t add trim(), because it removes whitespace when pasting text. This is what caused issue #74749. So we applied the fix here.

if (!clipboardText) {
return;
}

const selection = window.getSelection?.();
const selectedText = selection?.toString() ?? '';

if (isStandaloneURL(clipboardText) && selectedText) {
paste(toMarkdownLink(selectedText, clipboardText));
return;
}

paste(clipboardText);
},
[paste],
);
Expand Down Expand Up @@ -184,6 +194,10 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive
document.removeEventListener('paste', listener, true);
};
}, [isActive]);

return {
handlePastePlainText,
};
Comment thread
marufsharifi marked this conversation as resolved.
};

export default useHtmlPaste;
8 changes: 5 additions & 3 deletions src/hooks/useHtmlPaste/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type {MutableRefObject} from 'react';
import type {RefObject} from 'react';
import type {TextInput} from 'react-native';

type UseHtmlPaste = (
textInputRef: MutableRefObject<(HTMLTextAreaElement & TextInput) | TextInput | null>,
textInputRef: RefObject<(HTMLTextAreaElement & TextInput) | TextInput | null>,
preHtmlPasteCallback?: (event: ClipboardEvent) => boolean,
isActive?: boolean,
maxLength?: number, // Maximum length of the text input value after pasting
) => void;
) => void | {
handlePastePlainText: (event: ClipboardEvent) => void;
};

export default UseHtmlPaste;
95 changes: 95 additions & 0 deletions src/libs/MarkdownLinkHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {Str} from 'expensify-common';

/**
* Returns true if `text` is a single token URL (no whitespace), allowing optional angle-bracket wrappers.
*/
const isStandaloneURL = (text: string): boolean => {
if (!text) {
return false;
}
const trimmed = text.trim();
if (/\s/.test(trimmed)) {
return false;
}
const unwrapped = trimmed.replace(/^<|>$/g, '');

// Reject if contains emoji or any non-ASCII characters
// (valid URLs per RFC 3986 should be ASCII-only)
// eslint-disable-next-line no-control-regex
if (/[^\x00-\x7F]/.test(unwrapped)) {
return false;
}

return Str.isValidURL(unwrapped);
};

/**
* Collapse newlines to spaces, collapse repeated whitespace to single space, trim,
* and replace opening &#91; and closing &#93; square brackets with HTML codes, which would otherwise break Markdown link text.
*/
const escapeLinkText = (text: string): string => {
if (!text) {
return '';
}
const collapsed = text
.replace(/\r?\n+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return collapsed.replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
};

/**
* Sanitize the URL for Markdown link. Remove surrounding < > if present and encode it
* to avoid raw spaces or other invalid characters inside the parentheses.
* We won't alter semantics otherwise.
*/
const sanitizeUrlForMarkdown = (url: string): string => {
if (!isStandaloneURL(url)) {
return url;
}

const trimmed = (url || '').trim();
const unwrapped = trimmed.replace(/^<|>$/g, '');

try {
return encodeURI(unwrapped);
} catch {
return unwrapped;
}
};

/**
* Build a Markdown link: [escaped-selected-text](sanitized-url)
* We do NOT wrap the URL in < > here — angle brackets are only valid for "auto links", not inside link destinations.
*/
const toMarkdownLink = (selectedText: string, url: string): string => {
const safeText = escapeLinkText(selectedText);
const safeUrl = sanitizeUrlForMarkdown(url);
return `[${safeText}](${safeUrl})`;
};

type DetectRewriteResult = {text: string | null; didReplace: boolean};

/**
* Given prevText and a selection (start/end) and the insertedText (e.g. diff),
* returns a rewritten text if this looks like "selection replaced by a single URL" and we should turn it into a markdown link.
*/
const detectAndRewritePaste = (prevText: string, selectionStart: number, selectionEnd: number, insertedText: string): DetectRewriteResult => {
if (!insertedText || !isStandaloneURL(insertedText)) {
return {text: null, didReplace: false};
}

const replacedSelectionLength = Math.max(0, selectionEnd - selectionStart);
if (replacedSelectionLength === 0) {
// nothing replaced (user pasted URL without selecting text) -> don't rewrite
return {text: null, didReplace: false};
}

const selectedText = prevText.substring(selectionStart, selectionEnd);
const replacement = toMarkdownLink(selectedText, insertedText);
const newText = prevText.slice(0, selectionStart) + replacement + prevText.slice(selectionEnd);

return {text: newText, didReplace: true};
};

export {isStandaloneURL, escapeLinkText, sanitizeUrlForMarkdown, toMarkdownLink, detectAndRewritePaste};
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getPreferredSkinToneI
import focusComposerWithDelay from '@libs/focusComposerWithDelay';
import getPlatform from '@libs/getPlatform';
import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener';
import {detectAndRewritePaste} from '@libs/MarkdownLinkHelpers';
import Parser from '@libs/Parser';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUtils';
Expand Down Expand Up @@ -376,9 +377,30 @@ function ComposerWithSuggestions({
const updateComment = useCallback(
(commentValue: string, shouldDebounceSaveComment?: boolean) => {
raiseIsScrollLikelyLayoutTriggered();
const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue);
const isEmojiInserted = diff.length && endIndex > startIndex && diff.trim() === diff && containsOnlyEmojis(diff);
const commentWithSpaceInserted = isEmojiInserted ? insertWhiteSpaceAtIndex(commentValue, endIndex) : commentValue;

// previous text before change
const prevText = lastTextRef.current;
// snapshot selection (should be the selection that was active just before the paste/change)
const prevSelectionStart = selection?.start ?? 0;
const prevSelectionEnd = selection?.end ?? 0;

// detect newly added text (existing helper)
const {startIndex, endIndex, diff} = findNewlyAddedChars(prevText, commentValue);

// Try to rewrite if this looks like "selected text replaced with a single URL"
const {text: rewritten, didReplace} = detectAndRewritePaste(prevText, prevSelectionStart, prevSelectionEnd, diff);

// Use the rewritten text when we replaced; otherwise fall back to the original commentValue pipeline
const effectiveCommentValue = didReplace ? (rewritten ?? commentValue) : commentValue;

// Emoji handling: skip the "emoji inserted" special-case when we performed the markdown rewrite.
const isEmojiInserted =
!didReplace && // <-- use the didReplace flag instead of searching for '['
diff.length &&
endIndex > startIndex &&
diff.trim() === diff &&
containsOnlyEmojis(diff);
const commentWithSpaceInserted = isEmojiInserted ? insertWhiteSpaceAtIndex(effectiveCommentValue, endIndex) : effectiveCommentValue;
const {text: newComment, emojis, cursorPosition} = replaceAndExtractEmojis(commentWithSpaceInserted, preferredSkinTone, preferredLocale);
if (emojis.length) {
const newEmojis = getAddedEmojis(emojis, emojisPresentBefore.current);
Expand Down Expand Up @@ -426,7 +448,18 @@ function ComposerWithSuggestions({
debouncedBroadcastUserIsTyping(reportID);
}
},
[findNewlyAddedChars, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, selection.end],
[
findNewlyAddedChars,
preferredLocale,
preferredSkinTone,
reportID,
setIsCommentEmpty,
suggestionsRef,
raiseIsScrollLikelyLayoutTriggered,
debouncedSaveReportComment,
selection?.end,
selection?.start,
],
);

/**
Expand Down
Loading
Loading