From 7e3a89d0cc284ef68b69b47030c7be23f8a84c88 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Wed, 4 Mar 2026 13:01:13 -0600 Subject: [PATCH 1/4] Render nostr: NIP-27 mentions in longform articles Pre-process NIP-23 longform markdown to convert bare nostr: references into markdown links before MarkdownUI parses them. Resolves display names for npub/nprofile mentions via profile lookup and abbreviates note/nevent/naddr identifiers. Skips code blocks and existing links. Closes https://github.com/damus-io/damus/issues/3658 Closes https://github.com/damus-io/damus/issues/3426 Changelog-Added: Added clickable @mentions in longform articles Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.6 --- .../Features/Events/Models/NoteContent.swift | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/damus/Features/Events/Models/NoteContent.swift b/damus/Features/Events/Models/NoteContent.swift index 51d5ba2fe..ef7c5d582 100644 --- a/damus/Features/Events/Models/NoteContent.swift +++ b/damus/Features/Events/Models/NoteContent.swift @@ -68,7 +68,7 @@ func note_artifact_is_separated(kind: NostrKind?) -> Bool { func render_immediately_available_note_content(ndb: Ndb, ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> NoteArtifacts { if ev.known_kind == .longform { - return .longform(LongformContent(ev.content)) + return .longform(LongformContent(ev.content, profiles: profiles)) } do { @@ -385,10 +385,11 @@ struct LongformContent { return max(1, Int(ceil(Double(words) / 200.0))) } - init(_ markdown: String) { + init(_ markdown: String, profiles: Profiles) { + let mentionsResolved = LongformContent.resolveNostrMentions(in: markdown, profiles: profiles) // Pre-process markdown to ensure images are block-level (have blank lines around them) // This prevents images from being parsed as inline within text paragraphs - let processedMarkdown = LongformContent.ensureBlockLevelImages(markdown) + let processedMarkdown = LongformContent.ensureBlockLevelImages(mentionsResolved) let blocks = [BlockNode].init(markdown: processedMarkdown) self.markdown = MarkdownContent(blocks: blocks) self.words = count_markdown_words(blocks: blocks) @@ -556,6 +557,50 @@ struct LongformContent { return ranges } + + /// Wraps bare `nostr:` NIP-27 references as markdown links so MarkdownUI renders them as tappable. + static func resolveNostrMentions(in markdown: String, profiles: Profiles) -> String { + let pattern = #"nostr:(npub1|note1|nevent1|nprofile1|naddr1)[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return markdown + } + + let excludedRanges = findExcludedRanges(in: markdown) + let matches = regex.matches(in: markdown, options: [], range: NSRange(markdown.startIndex..., in: markdown)) + + var result = markdown + for match in matches.reversed() { + guard let range = Range(match.range, in: result) else { continue } + + if excludedRanges.contains(where: { $0.overlaps(range) }) { continue } + + // Skip if already inside a markdown link URL: preceded by "](" + if range.lowerBound > result.index(result.startIndex, offsetBy: 1), + result[result.index(range.lowerBound, offsetBy: -2).. Int { From 0fd96d95e43e3a8001f7aa7cc9d704f02a214003 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Wed, 4 Mar 2026 13:58:47 -0600 Subject: [PATCH 2/4] Add tests for NIP-27 mention resolution in longform articles Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.6 --- damusTests/NoteContentViewTests.swift | 61 +++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/damusTests/NoteContentViewTests.swift b/damusTests/NoteContentViewTests.swift index 10a00d561..bb5f4720b 100644 --- a/damusTests/NoteContentViewTests.swift +++ b/damusTests/NoteContentViewTests.swift @@ -436,6 +436,67 @@ class NoteContentViewTests: XCTestCase { assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: bech) } + // MARK: - resolveNostrMentions + + @MainActor + func testResolveNostrMentions_bareNpub_wrapsInMarkdownLink() { + let npub = test_pubkey.npub + let input = "Hello nostr:\(npub) world" + let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles) + + XCTAssertTrue(result.contains("](nostr:\(npub))"), "Bare npub should be wrapped in markdown link") + XCTAssertFalse(result.hasPrefix("["), "Text before mention should be preserved") + XCTAssertTrue(result.contains("Hello "), "Text before mention should be preserved") + XCTAssertTrue(result.contains(" world"), "Text after mention should be preserved") + } + + @MainActor + func testResolveNostrMentions_bareNote_wrapsInMarkdownLink() { + let noteId = test_note.id.bech32 + let input = "Check nostr:\(noteId) out" + let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles) + + XCTAssertTrue(result.contains("](nostr:\(noteId))"), "Bare note should be wrapped in markdown link") + } + + @MainActor + func testResolveNostrMentions_existingMarkdownLink_skipped() { + let npub = test_pubkey.npub + let input = "[some text](nostr:\(npub))" + let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles) + + XCTAssertEqual(result, input, "Already-linked nostr reference should not be double-wrapped") + } + + @MainActor + func testResolveNostrMentions_insideCodeBlock_skipped() { + let npub = test_pubkey.npub + let input = "```\nnostr:\(npub)\n```" + let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles) + + XCTAssertEqual(result, input, "nostr reference inside code block should not be modified") + } + + @MainActor + func testResolveNostrMentions_multipleMentions_allWrapped() { + let npub = test_pubkey.npub + let noteId = test_note.id.bech32 + let input = "By nostr:\(npub) see nostr:\(noteId)" + let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles) + + XCTAssertTrue(result.contains("](nostr:\(npub))"), "npub mention should be wrapped") + XCTAssertTrue(result.contains("](nostr:\(noteId))"), "note mention should be wrapped") + } + + @MainActor + func testResolveNostrMentions_npubDisplayText_hasAtPrefix() { + let npub = test_pubkey.npub + let input = "nostr:\(npub)" + let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles) + + XCTAssertTrue(result.hasPrefix("[@"), "npub display text should start with @") + } + } private func assertCompatibleTextHasExpectedString(compatibleText: CompatibleText, expected: String) { From 4be0afc6d7c6998cd4f21c5139fd3d93bbe05418 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Wed, 4 Mar 2026 13:59:11 -0600 Subject: [PATCH 3/4] Fix unexpected line breaks from Unicode separators in longform posts Strip U+2028 (Line Separator) and U+2029 (Paragraph Separator) during longform markdown preprocessing. These characters pass through the CommonMark parser as text but SwiftUI Text renders them as visible line breaks, causing extra spacing not intended by the author. Closes https://github.com/damus-io/damus/issues/1581 Changelog-Fixed: Fixed unexpected line breaks in longform articles Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.6 --- damus/Features/Events/Models/NoteContent.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/damus/Features/Events/Models/NoteContent.swift b/damus/Features/Events/Models/NoteContent.swift index ef7c5d582..c9334d65d 100644 --- a/damus/Features/Events/Models/NoteContent.swift +++ b/damus/Features/Events/Models/NoteContent.swift @@ -386,7 +386,8 @@ struct LongformContent { } init(_ markdown: String, profiles: Profiles) { - let mentionsResolved = LongformContent.resolveNostrMentions(in: markdown, profiles: profiles) + let sanitized = LongformContent.sanitizeUnicodeSeparators(markdown) + let mentionsResolved = LongformContent.resolveNostrMentions(in: sanitized, profiles: profiles) // Pre-process markdown to ensure images are block-level (have blank lines around them) // This prevents images from being parsed as inline within text paragraphs let processedMarkdown = LongformContent.ensureBlockLevelImages(mentionsResolved) @@ -601,6 +602,13 @@ struct LongformContent { return result } + + /// Replaces Unicode line/paragraph separators (U+2028, U+2029) that cause unexpected breaks in SwiftUI Text. + static func sanitizeUnicodeSeparators(_ markdown: String) -> String { + return markdown + .replacingOccurrences(of: "\u{2028}", with: " ") + .replacingOccurrences(of: "\u{2029}", with: "\n\n") + } } func count_markdown_words(blocks: [BlockNode]) -> Int { From 2dd5050c5041179369d94b5cf86ba49200aa8292 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Wed, 4 Mar 2026 14:01:36 -0600 Subject: [PATCH 4/4] Add tests for Unicode separator sanitization and additional mention types Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.6 --- damusTests/NoteContentViewTests.swift | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/damusTests/NoteContentViewTests.swift b/damusTests/NoteContentViewTests.swift index bb5f4720b..0bc2ea533 100644 --- a/damusTests/NoteContentViewTests.swift +++ b/damusTests/NoteContentViewTests.swift @@ -497,6 +497,64 @@ class NoteContentViewTests: XCTestCase { XCTAssertTrue(result.hasPrefix("[@"), "npub display text should start with @") } + @MainActor + func testResolveNostrMentions_nprofileDisplayText() { + let nprofile = "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p" + let input = "By nostr:\(nprofile) today" + let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles) + + XCTAssertTrue(result.contains("](nostr:\(nprofile))"), "nprofile should be wrapped in markdown link") + XCTAssertTrue(result.contains("[@"), "nprofile display text should start with @") + } + + @MainActor + func testResolveNostrMentions_naddrDisplayText() { + let naddr = "naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld" + let input = "See nostr:\(naddr) for details" + let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles) + + XCTAssertTrue(result.contains("](nostr:\(naddr))"), "naddr should be wrapped in markdown link") + XCTAssertTrue(result.contains("[naddr1qq:3cnmhuld]"), "naddr should use abbreviated identifier") + } + + @MainActor + func testResolveNostrMentions_insideInlineCode_skipped() { + let npub = test_pubkey.npub + let input = "Use `nostr:\(npub)` in your client" + let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles) + + XCTAssertEqual(result, input, "nostr reference inside inline code should not be modified") + } + + // MARK: - sanitizeUnicodeSeparators + + func testSanitizeUnicodeSeparators_lineSeparator_replacedWithSpace() { + let input = "Hello\u{2028}world" + let result = LongformContent.sanitizeUnicodeSeparators(input) + + XCTAssertEqual(result, "Hello world", "U+2028 should be replaced with space") + } + + func testSanitizeUnicodeSeparators_paragraphSeparator_replacedWithDoubleNewline() { + let input = "Hello\u{2029}world" + let result = LongformContent.sanitizeUnicodeSeparators(input) + + XCTAssertEqual(result, "Hello\n\nworld", "U+2029 should be replaced with double newline") + } + + func testSanitizeUnicodeSeparators_noSeparators_unchanged() { + let input = "Hello world\nNew line" + let result = LongformContent.sanitizeUnicodeSeparators(input) + + XCTAssertEqual(result, input, "Normal text should be unchanged") + } + + func testSanitizeUnicodeSeparators_multipleSeparators_allReplaced() { + let input = "A\u{2028}B\u{2028}C\u{2029}D" + let result = LongformContent.sanitizeUnicodeSeparators(input) + + XCTAssertEqual(result, "A B C\n\nD") + } } private func assertCompatibleTextHasExpectedString(compatibleText: CompatibleText, expected: String) {