Skip to content
Open
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
59 changes: 56 additions & 3 deletions damus/Features/Events/Models/NoteContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -385,10 +385,12 @@ struct LongformContent {
return max(1, Int(ceil(Double(words) / 200.0)))
}

init(_ markdown: String) {
init(_ markdown: String, 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(markdown)
let processedMarkdown = LongformContent.ensureBlockLevelImages(mentionsResolved)
let blocks = [BlockNode].init(markdown: processedMarkdown)
self.markdown = MarkdownContent(blocks: blocks)
self.words = count_markdown_words(blocks: blocks)
Expand Down Expand Up @@ -556,6 +558,57 @@ 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)..<range.lowerBound] == "](" {
continue
}
Comment on lines +569 to +582
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Existing-link detection is too narrow and can produce nested markdown links.

Current skip logic only checks whether the match is preceded by "](", so nostr: inside markdown link labels (e.g. [nostr:npub...](https://...)) can still be wrapped and break markdown rendering.

💡 Suggested fix
 static func resolveNostrMentions(in markdown: String, profiles: Profiles) -> String {
@@
-    let excludedRanges = findExcludedRanges(in: markdown)
+    let excludedRanges = findExcludedRanges(in: markdown)
+    let markdownLinkRanges = findMarkdownLinkRanges(in: markdown)
@@
-        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)..<range.lowerBound] == "](" {
+        if excludedRanges.contains(where: { $0.overlaps(range) }) ||
+           markdownLinkRanges.contains(where: { $0.overlaps(range) }) {
             continue
         }
@@
 }
+
+private static func findMarkdownLinkRanges(in markdown: String) -> [Range<String.Index>] {
+    let pattern = #"\[[^\]]*\]\([^)]+\)"#
+    guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
+    let nsRange = NSRange(markdown.startIndex..., in: markdown)
+    return regex.matches(in: markdown, range: nsRange)
+        .compactMap { Range($0.range, in: markdown) }
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)..<range.lowerBound] == "](" {
continue
}
let excludedRanges = findExcludedRanges(in: markdown)
let markdownLinkRanges = findMarkdownLinkRanges(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) }) ||
markdownLinkRanges.contains(where: { $0.overlaps(range) }) {
continue
}
}
}
/// Finds all ranges occupied by complete markdown links in the given string.
/// This prevents wrapping `nostr:` mentions that appear in markdown link labels or URLs.
/// - Parameter markdown: The string to search for markdown links
/// - Returns: An array of ranges, each spanning a complete markdown link `[label](url)`
private static func findMarkdownLinkRanges(in markdown: String) -> [Range<String.Index>] {
let pattern = #"\[[^\]]*\]\([^)]+\)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let nsRange = NSRange(markdown.startIndex..., in: markdown)
return regex.matches(in: markdown, range: nsRange)
.compactMap { Range($0.range, in: markdown) }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Events/Models/NoteContent.swift` around lines 569 - 582, The
current check only skips matches preceded by "](", which misses matches inside
markdown link labels and allows nested links; update the loop that iterates
matches (using variables regex, matches, result, range, excludedRanges) to also
detect and skip matches that are inside a markdown link label or URL: before
wrapping a match, scan left from range.lowerBound for a '[' with no intervening
']' (indicating start of a label) and scan right from range.upperBound for a ']'
followed immediately by '(' (indicating a link target); if either condition is
true (or the existing "](" check), continue (skip) so matches inside
"[...](...)" are not wrapped. Ensure checks use safe Range/Index operations on
result and account for string boundaries.


let nostrURI = String(result[range])
let bech32 = String(nostrURI.dropFirst(6))
guard let decoded = Bech32Object.parse(bech32) else { continue }

let displayText: String
switch decoded {
case .npub(let pk):
displayText = "@\(getDisplayName(pk: pk, profiles: profiles))"
case .nprofile(let nprofile):
displayText = "@\(getDisplayName(pk: nprofile.author, profiles: profiles))"
case .note, .nevent, .naddr:
displayText = abbrev_identifier(bech32)
default:
continue
}

result.replaceSubrange(range, with: "[\(displayText)](\(nostrURI))")
Comment on lines +588 to +600
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Escape mention display text before injecting it into markdown link text.

displayText is derived from profile metadata and is inserted directly into [text](url); unescaped []()\\ can break markdown or alter rendered links.

💡 Suggested fix
-            result.replaceSubrange(range, with: "[\(displayText)](\(nostrURI))")
+            let safeDisplayText = escapeMarkdownLinkText(displayText)
+            result.replaceSubrange(range, with: "[\(safeDisplayText)](\(nostrURI))")
@@
     return result
 }
+
+private static func escapeMarkdownLinkText(_ text: String) -> String {
+    var escaped = text.replacingOccurrences(of: "\\", with: "\\\\")
+    escaped = escaped.replacingOccurrences(of: "[", with: "\\[")
+    escaped = escaped.replacingOccurrences(of: "]", with: "\\]")
+    escaped = escaped.replacingOccurrences(of: "(", with: "\\(")
+    escaped = escaped.replacingOccurrences(of: ")", with: "\\)")
+    return escaped
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Events/Models/NoteContent.swift` around lines 588 - 600,
displayText is inserted unescaped into markdown link text which allows
characters like [, ], (, ), and \ from getDisplayName(...) to break the link;
before calling result.replaceSubrange(... with: "[\(displayText)](\(nostrURI))")
escape Markdown meta-characters (at minimum [, ], (, ), and backslash) in
displayText (e.g., via a helper like escapeMarkdownText or sanitizeDisplayName)
and use the escaped string when building the link; update all branches that
assign displayText (the .npub, .nprofile, and fallback cases) to apply the
escape so replaceSubrange always receives a safe value.

}

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 {
Expand Down
119 changes: 119 additions & 0 deletions damusTests/NoteContentViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,125 @@ 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 @")
}

@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) {
Expand Down