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;