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
5 changes: 5 additions & 0 deletions .changeset/fix-link-editing-issues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix issues related to editing messages with links losing previews or gaining \<>
9 changes: 9 additions & 0 deletions src/app/components/editor/getLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/editor/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
13 changes: 10 additions & 3 deletions src/app/features/room/message/MessageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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('<a') && `&lt;${s[0]}`) || `${s[0]}&lt;`)) || `${s[0]}<` : s[0]}${strippedS}${isHidden ? (isHTML && '&gt;') || '>' : ''}`;

// Wrap whole <a>…</a> as &lt;…&gt; once; duplicating the leading "<" breaks htmlToMarkdown's [<][a][>] detection.
if (isHidden && isHTML && s.toLowerCase().startsWith('<a')) {
newBody += `&lt;${s}&gt;`;
return;
}

newBody += `${isHidden ? (isHTML && `${s[0]}&lt;`) || `${s[0]}<` : s[0]}${strippedS}${isHidden ? (isHTML && '&gt;') || '>' : ''}`;
});
return newBody;
};
Expand Down
25 changes: 25 additions & 0 deletions src/app/features/room/message/hiddenLinkPreviews.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ describe('stripMarkdownEscapesForHiddenPreviews', () => {
String.raw`keep \*this\* and \<not-a-url\>`
);
});

it('unwraps outer \\< \\> around a preview-suppressed markdown link from htmlToMarkdown', () => {
expect(
stripMarkdownEscapesForHiddenPreviews(
String.raw`\<[https://example.org/](<https://example.org/>)\>`
)
).toBe('[https://example.org/](<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/](<https://example.com/>)');
});
});

describe('readdAngleBracketsForHiddenPreviews', () => {
Expand All @@ -47,4 +63,13 @@ describe('readdAngleBracketsForHiddenPreviews', () => {
'see <https://example.org/>'
);
});

it('does not corrupt markdown suppressed links [url](<url>)', () => {
expect(
readdAngleBracketsForHiddenPreviews(
'see [https://example.org/](<https://example.org/>) thanks',
[]
)
).toBe('see [https://example.org/](<https://example.org/>) thanks');
});
});
33 changes: 30 additions & 3 deletions src/app/features/room/message/hiddenLinkPreviews.ts
Original file line number Diff line number Diff line change
@@ -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');

/**
Expand All @@ -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](<url>) after htmlToMarkdown escaped a surrounding "\<...\>".
const ESCAPED_SUPPRESSED_MD_LINK = new RegExp(
String.raw`\\<\[([^\]]*)\]\((<https?:\/\/[^>\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(
Expand All @@ -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 ...](<https://...>)
if (offset >= 3 && body.slice(offset - 3, offset) === '](<') {
return full;
}

// If the URL is already wrapped as <url>, leave it alone.
const urlIndex = body.indexOf(url, offset);
if (urlIndex !== -1 && body.slice(urlIndex - 1, urlIndex + url.length + 1) === `<${url}>`) {
Expand Down
5 changes: 5 additions & 0 deletions src/app/plugins/markdown/htmlToMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ describe('htmlToMarkdown', () => {
expect(htmlToMarkdown(html)).toBe('[https://example.org/](<https://example.org/>)');
});

it('converts hidden-preview wrapped links when angle brackets are decimal entities', () => {
const html = '<p>&#60;<a href="https://example.org/">https://example.org/</a>&#62;</p>';
expect(htmlToMarkdown(html)).toBe('[https://example.org/](<https://example.org/>)');
});

it('converts spoiler spans', () => {
expect(htmlToMarkdown('<span data-mx-spoiler>hidden</span>')).toContain('||hidden||');
});
Expand Down
16 changes: 14 additions & 2 deletions src/app/plugins/markdown/htmlToMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === '&lt;' || t === '&#60;' || t === '&#x3c;';
}

function isClosingAngleBracketText(data: string): boolean {
const t = data.trim();
return t === '>' || t === '&gt;' || t === '&#62;' || t === '&#x3e;';
}

function processChildren(
children: ChildNode[],
listDepth: number = 0,
Expand All @@ -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](<href>) so bracket text is not run through escapeMarkdown as "\<".
out.push(`[${content}](<${href}>)`);
i += 2;
continue;
Expand Down
Loading