From 988109d39f198a0f4d73e329a5d680c54ea3ae90 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 08:09:26 -0800 Subject: [PATCH 1/2] fix(messages): keep names in literal Markdown as written when editing Opening Edit turned every bound name into a profile link, including names inside HTML, link titles, reference definitions and code. Saving then published that link Markdown inside them, for example in a definition's URL or a link's title. The timeline and Copy skip a name that overlaps literal Markdown; Edit now does the same, with the renderer's literalRanges (exported for this) and the overlap check Copy uses. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/messages/markdown-preparation.ts | 2 +- src/features/messages/message-edit.test.ts | 19 ++++++++++++++ src/features/messages/message-edit.ts | 26 ++++++++++++------- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/features/messages/markdown-preparation.ts b/src/features/messages/markdown-preparation.ts index 42f185c33f..cccd40ea63 100644 --- a/src/features/messages/markdown-preparation.ts +++ b/src/features/messages/markdown-preparation.ts @@ -36,7 +36,7 @@ export const isLiteralMarkdownContext = (type: string) => "html", ].includes(type); -function literalRanges( +export function literalRanges( tree: MarkdownNode, includeLinks: boolean, ): readonly LiteralRange[] { diff --git a/src/features/messages/message-edit.test.ts b/src/features/messages/message-edit.test.ts index 422a57bb82..05a61d210b 100644 --- a/src/features/messages/message-edit.test.ts +++ b/src/features/messages/message-edit.test.ts @@ -75,6 +75,25 @@ describe("message edit round trips", () => { const row = firstRow([{ ...original, tags: [["h", "room"]] }]); expect(messageEditText(row, profiles)).toBe(original.content); }); + it.each([ + "[ref]: ", + '[ref]: https://example.com "Ask @Sam"', + "", + 'Hi', + '[[a]](https://example.com "See @Sam")', + '[a](https://example.com\n "See @Sam")', + "[hello\n@Sam](https://example.com)", + "> @Sam", + ])("does not manufacture mentions in literal Markdown: %s", (literal) => { + const content = `Hello @Sam\n\n${literal}`; + const tags = [ + ["h", "room"], + ["p", person], + ]; + expect( + messageEditText(firstRow([{ ...original, content, tags }]), profiles), + ).toBe(`Hello [@Sam](${profileTarget(person)})\n\n${literal}`); + }); }); it("rejects changed attachment links while allowing text-only edits", () => { diff --git a/src/features/messages/message-edit.ts b/src/features/messages/message-edit.ts index d7b9bf891f..04077bf9ba 100644 --- a/src/features/messages/message-edit.ts +++ b/src/features/messages/message-edit.ts @@ -1,7 +1,11 @@ -import { projectMarkdownAttachments } from "../relay/message-content"; +import { + projectMarkdownAttachments, + scanMarkdown, +} from "../relay/message-content"; import type { AgentLibrary } from "../agents/library"; import type { ChannelMessage, Profile } from "../relay/contracts"; import { npubEncode } from "nostr-tools/nip19"; +import { literalRanges } from "./markdown-preparation"; import type { MentionRecipient } from "./mention-draft"; import { profileMentionParts } from "./profile-mentions"; @@ -27,14 +31,18 @@ export function messageEditText( agents: AgentLibrary["identities"] = [], ) { const { attachmentSeams: _seams, ...source } = row; - return profileMentionParts( - { ...source, content: row.sourceContent ?? row.content }, - profiles, - agents, - ) - .map(({ text, target }) => - target ? profileMentionLink(text, target) : text, - ) + const content = row.sourceContent ?? row.content; + const ranges = literalRanges(scanMarkdown(content).tree, true); + let offset = 0; + return profileMentionParts({ ...source, content }, profiles, agents) + .map(({ text, target }) => { + const start = offset; + offset += text.length; + return target && + !ranges.some((range) => start < range.end && offset > range.start) + ? profileMentionLink(text, target) + : text; + }) .join(""); } From bcc62911ecb59c7dd4d2ab3bfb13a891646ab3aa Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 09:04:35 -0800 Subject: [PATCH 2/2] perf(messages): check literal ranges in order when opening Edit Each linked name was checked against every literal range, so a message with many names beside code spans took up to about 270 ms to open in Edit at 32 KiB. Walk the ranges forward with the name parts, as the timeline does. The regression table now puts the linked name right beside HTML, so a check that also drops a touching name fails it. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/messages/message-edit.test.ts | 6 +++--- src/features/messages/message-edit.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/features/messages/message-edit.test.ts b/src/features/messages/message-edit.test.ts index 05a61d210b..de6e977770 100644 --- a/src/features/messages/message-edit.test.ts +++ b/src/features/messages/message-edit.test.ts @@ -84,15 +84,15 @@ describe("message edit round trips", () => { '[a](https://example.com\n "See @Sam")', "[hello\n@Sam](https://example.com)", "> @Sam", - ])("does not manufacture mentions in literal Markdown: %s", (literal) => { - const content = `Hello @Sam\n\n${literal}`; + ])("links a name beside literal Markdown, not inside it: %s", (literal) => { + const content = `@Sam\n\n${literal}`; const tags = [ ["h", "room"], ["p", person], ]; expect( messageEditText(firstRow([{ ...original, content, tags }]), profiles), - ).toBe(`Hello [@Sam](${profileTarget(person)})\n\n${literal}`); + ).toBe(`[@Sam](${profileTarget(person)})\n\n${literal}`); }); }); diff --git a/src/features/messages/message-edit.ts b/src/features/messages/message-edit.ts index 04077bf9ba..7a21bb3d2e 100644 --- a/src/features/messages/message-edit.ts +++ b/src/features/messages/message-edit.ts @@ -34,12 +34,13 @@ export function messageEditText( const content = row.sourceContent ?? row.content; const ranges = literalRanges(scanMarkdown(content).tree, true); let offset = 0; + let next = 0; return profileMentionParts({ ...source, content }, profiles, agents) .map(({ text, target }) => { const start = offset; offset += text.length; - return target && - !ranges.some((range) => start < range.end && offset > range.start) + while ((ranges[next]?.end ?? Infinity) <= start) next++; + return target && (ranges[next]?.start ?? Infinity) >= offset ? profileMentionLink(text, target) : text; })