diff --git a/.changeset/fix-link-editing-issues.md b/.changeset/fix-link-editing-issues.md new file mode 100644 index 000000000..aecc7907b --- /dev/null +++ b/.changeset/fix-link-editing-issues.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix issues related to editing messages with links losing previews or gaining \<> diff --git a/src/app/components/editor/getLinks.test.ts b/src/app/components/editor/getLinks.test.ts index 7f830a557..a184ec55a 100644 --- a/src/app/components/editor/getLinks.test.ts +++ b/src/app/components/editor/getLinks.test.ts @@ -32,6 +32,15 @@ describe('getLinks', () => { expect(links).toContain('https://example.com'); }); + it('does not merge link text URL with destination when both are https (edited bare link)', () => { + const node: ParagraphElement = { + type: BlockType.Paragraph, + children: [{ text: '[https://example.com/](https://example.com/)' }], + }; + const links = getLinks([node]); + expect(links).toEqual(['https://example.com/']); + }); + it('excludes URLs inside markdown inline code spans', () => { const node: ParagraphElement = { type: BlockType.Paragraph, diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 33baeac15..665da06a9 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -156,7 +156,7 @@ const elementToPlainText = (node: CustomElement, children: string): string => { }; const SPOILERINPUTREGEX = /\|\|.+?\|\|/g; -const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)`; +const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\()@!$&'*+,;%=]+)`; export const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g'); const SPOILEREDLINKINPUTREGEX = new RegExp(`<(${LINK_URL})>`, 'g'); const SPOILEREDLINKDIRECTREGEX = new RegExp(`\\|\\|(${LINK_URL})\\|\\|`, 'g'); diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 9ac8b85cd..341a227a0 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -121,9 +121,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( ); } - const bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; + const bundleContent = + (content['com.beeper.linkpreviews'] as BundleContent[] | undefined) ?? []; const markHiddenLinks = (original: string, isHTML?: boolean) => { - if (!bundleContent) return original; if (!isHTML) { return readdAngleBracketsForHiddenPreviews(original, bundleContent); } @@ -155,7 +155,14 @@ export const MessageEditor = as<'div', MessageEditorProps>( (bundleContent?.length === 0 || bundleContent.filter((b) => s.includes(b.matched_url)).length === 0) && strippedS.match(LINKINPUTREGEX) !== null; - newBody += `${isHidden ? (isHTML && ((s.startsWith('' : ''}`; + + // Wrap whole as <…> once; duplicating the leading "<" breaks htmlToMarkdown's [<][a][>] detection. + if (isHidden && isHTML && s.toLowerCase().startsWith('' : ''}`; }); return newBody; }; diff --git a/src/app/features/room/message/hiddenLinkPreviews.test.ts b/src/app/features/room/message/hiddenLinkPreviews.test.ts index 60ff23d0d..86b959eb7 100644 --- a/src/app/features/room/message/hiddenLinkPreviews.test.ts +++ b/src/app/features/room/message/hiddenLinkPreviews.test.ts @@ -25,6 +25,22 @@ describe('stripMarkdownEscapesForHiddenPreviews', () => { String.raw`keep \*this\* and \` ); }); + + it('unwraps outer \\< \\> around a preview-suppressed markdown link from htmlToMarkdown', () => { + expect( + stripMarkdownEscapesForHiddenPreviews( + String.raw`\<[https://example.org/]()\>` + ) + ).toBe('[https://example.org/]()'); + }); + + it('fixes escaped outer brackets when destination lost angle brackets (bad HTML wrap)', () => { + expect( + stripMarkdownEscapesForHiddenPreviews( + String.raw`\<[https://example.com/](https://example.com/)>` + ) + ).toBe('[https://example.com/]()'); + }); }); describe('readdAngleBracketsForHiddenPreviews', () => { @@ -47,4 +63,13 @@ describe('readdAngleBracketsForHiddenPreviews', () => { 'see ' ); }); + + it('does not corrupt markdown suppressed links [url]()', () => { + expect( + readdAngleBracketsForHiddenPreviews( + 'see [https://example.org/]() thanks', + [] + ) + ).toBe('see [https://example.org/]() thanks'); + }); }); diff --git a/src/app/features/room/message/hiddenLinkPreviews.ts b/src/app/features/room/message/hiddenLinkPreviews.ts index 7ca3111dc..7c8ed7e54 100644 --- a/src/app/features/room/message/hiddenLinkPreviews.ts +++ b/src/app/features/room/message/hiddenLinkPreviews.ts @@ -1,6 +1,6 @@ import type { BundleContent } from '$components/message'; -const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)`; +const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\()@!$&'*+,;%=]+)`; const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g'); /** @@ -18,7 +18,20 @@ export function stripMarkdownEscapesForHiddenPreviews(markdown: string): string const OPEN_ONLY = new RegExp(String.raw`\\<(${LINK_URL})`, 'g'); const CLOSE_ONLY = new RegExp(String.raw`(${LINK_URL})\\>`, 'g'); - return markdown.replace(WRAPPED, '<$1>').replace(OPEN_ONLY, '<$1').replace(CLOSE_ONLY, '$1>'); + let s = markdown.replace(WRAPPED, '<$1>').replace(OPEN_ONLY, '<$1').replace(CLOSE_ONLY, '$1>'); + + // Restore [label]() after htmlToMarkdown escaped a surrounding "\<...\>". + const ESCAPED_SUPPRESSED_MD_LINK = new RegExp( + String.raw`\\<\[([^\]]*)\]\((\s]+>)\)\\>`, + 'g' + ); + s = s.replace(ESCAPED_SUPPRESSED_MD_LINK, '[$1]($2)'); + + // Same for "\<[label](bare-url)>" when angle brackets were lost on the destination. + const WRONG_OUTER_ESCAPED_AUTOLINK = /\\<\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)(?:>|\\>)/g; + s = s.replace(WRONG_OUTER_ESCAPED_AUTOLINK, '[$1](<$2>)'); + + return s; } export function readdAngleBracketsForHiddenPreviews( @@ -30,9 +43,23 @@ export function readdAngleBracketsForHiddenPreviews( const previewed = new Set(linkPreviews.map((b) => b.matched_url)); LINKINPUTREGEX.lastIndex = 0; - return body.replace(LINKINPUTREGEX, (full, url: string, offset: number) => { + return body.replace(LINKINPUTREGEX, (...args: unknown[]) => { + const full = args[0] as string; + const url = args[args.length - 3] as string; + const offset = args[args.length - 2] as number; if (!url || previewed.has(url)) return full; + // URL is the label of a markdown link [url](...) — do not insert "<" into the label. + const after = body.slice(offset + full.length, offset + full.length + 2); + if (after === '](') { + return full; + } + + // Already a preview-suppressed destination ...]() + if (offset >= 3 && body.slice(offset - 3, offset) === '](<') { + return full; + } + // If the URL is already wrapped as , leave it alone. const urlIndex = body.indexOf(url, offset); if (urlIndex !== -1 && body.slice(urlIndex - 1, urlIndex + url.length + 1) === `<${url}>`) { diff --git a/src/app/plugins/markdown/htmlToMarkdown.test.ts b/src/app/plugins/markdown/htmlToMarkdown.test.ts index de03b06b9..8ca43b6ad 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.test.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.test.ts @@ -67,6 +67,11 @@ describe('htmlToMarkdown', () => { expect(htmlToMarkdown(html)).toBe('[https://example.org/]()'); }); + it('converts hidden-preview wrapped links when angle brackets are decimal entities', () => { + const html = '

<https://example.org/>

'; + expect(htmlToMarkdown(html)).toBe('[https://example.org/]()'); + }); + it('converts spoiler spans', () => { expect(htmlToMarkdown('hidden')).toContain('||hidden||'); }); diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts index 1a42d5613..967fb99b7 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -196,6 +196,17 @@ function processInlineElements( return processChildren(node.children, listDepth, insideCode); } +/** Text node is a literal or entity-encoded angle bracket (preview-suppressed autolink wrapper). */ +function isOpeningAngleBracketText(data: string): boolean { + const t = data.trim(); + return t === '<' || t === '<' || t === '<' || t === '<'; +} + +function isClosingAngleBracketText(data: string): boolean { + const t = data.trim(); + return t === '>' || t === '>' || t === '>' || t === '>'; +} + function processChildren( children: ChildNode[], listDepth: number = 0, @@ -213,14 +224,15 @@ function processChildren( next && next2 && isText(cur) && - cur.data === '<' && + isOpeningAngleBracketText(cur.data) && isTag(next) && next.name.toLowerCase() === 'a' && isText(next2) && - next2.data === '>' + isClosingAngleBracketText(next2.data) ) { const href = next.attribs.href ?? ''; const content = next.children.map((c) => processNode(c, listDepth, insideCode)).join(''); + // Suppressed autolink: [label]() so bracket text is not run through escapeMarkdown as "\<". out.push(`[${content}](<${href}>)`); i += 2; continue;