Skip to content

Render nostr: mentions and fix line breaks in longform articles#3659

Open
alltheseas wants to merge 4 commits intodamus-io:masterfrom
alltheseas:longform-nostr-mentions
Open

Render nostr: mentions and fix line breaks in longform articles#3659
alltheseas wants to merge 4 commits intodamus-io:masterfrom
alltheseas:longform-nostr-mentions

Conversation

@alltheseas
Copy link
Copy Markdown
Collaborator

@alltheseas alltheseas commented Mar 4, 2026

Summary

  • Render bare nostr: NIP-27 references as clickable @mention links in longform (NIP-23) articles. MarkdownUI treats nostr:npub1... as plain text; this pre-processes the markdown to wrap them as [@DisplayName](nostr:bech32) before parsing.
  • Fix unexpected line breaks caused by Unicode U+2028/U+2029 separators in longform posts. These pass through CommonMark as text but SwiftUI Text renders them as visible breaks.

Checklist

Standard PR Checklist

  • I have read (or I am familiar with) the Contribution Guidelines
  • I have tested the changes in this PR
  • I have profiled the changes to ensure there are no performance regressions, or I do not need to profile the changes.
    • Not needed: changes are string preprocessing that runs once per longform article render, negligible cost.
  • I have opened or referred to an existing github issue related to this change.
  • My PR is either small, or I have split it into smaller logical commits that are easier to review
  • I have added the signoff line to all my commits. See Signing off your work
  • I have added appropriate changelog entries for the changes in this PR. See Adding changelog entries
  • I have added appropriate Closes: or Fixes: tags in the commit messages wherever applicable, or made sure those are not needed. See Submitting patches

Test report

Device: iPhone 17 Pro (Simulator)

iOS: 26.2

Damus: dfacd124 (this branch)

Setup: Loaded longform articles containing nostr:npub, nostr:nprofile, and nostr:note references, including the Nostr Compass newsletter referenced in #3426.

Steps:

  1. Build and run on iOS Simulator
  2. Open the Nostr Compass newsletter article (nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9)
  3. Verify nostr:npub1... and nostr:nprofile1... render as clickable @DisplayName links
  4. Verify nostr:note1... and nostr:nevent1... render as abbreviated clickable links
  5. Verify tapping a mention navigates to the profile
  6. Verify code blocks containing nostr: text are untouched
  7. Open the stacker.news longform article from Unexpected line breaks in longform posts #1581 (note16vl9z038l3pgq8prya0zjx7wja3ume37ryzuagwga0ksetfchtdqj56yc6)
  8. Verify no unexpected extra line breaks between paragraphs

Results:

  • PASS

Other notes

  • The test target has pre-existing build failures in PostViewTests.swift and ProfilesManagerTests.swift (TagsIterator type mismatches) that prevent test execution. These are unrelated to this PR. Unit tests for resolveNostrMentions and sanitizeUnicodeSeparators are included and compile correctly.
  • Bare npub1... references without the nostr: prefix (non-NIP-27 compliant) are intentionally not matched, as that is an author formatting error per the NIP-27 spec.

Summary by CodeRabbit

  • New Features
    • Longform notes now convert bare Nostr references into clickable links, showing profile names or shortened identifiers.
  • Bug Fixes
    • Fixed rendering issues caused by Unicode line/paragraph separators to ensure correct note display.
  • Tests
    • Added comprehensive tests covering mention conversion and Unicode separator handling.

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 damus-io#3658
Closes damus-io#3426

Changelog-Added: Added clickable @mentions in longform articles

Signed-off-by: alltheseas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alltheseas alltheseas requested a review from danieldaquino March 4, 2026 19:46
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 4, 2026

📝 Walkthrough

Walkthrough

Preprocessing for longform notes now sanitizes Unicode line/paragraph separators and resolves bare Nostr NIP-27 mentions into Markdown links using profile data. The LongformContent initializer requires Profiles and applies these steps before further markdown/image normalization.

Changes

Cohort / File(s) Summary
Core Preprocessing Logic
damus/Features/Events/Models/NoteContent.swift
LongformContent initializer changed from init(_ markdown: String) to init(_ markdown: String, profiles: Profiles); added sanitizeUnicodeSeparators(_:) and resolveNostrMentions(in:profiles:); mention-resolution wraps bare npub/note/nprofile/naddr references as Markdown links using profile-derived display text; Unicode separators U+2028 -> space and U+2029 -> double newline; mention resolution output is used for image-block normalization.
Unit Tests
damusTests/NoteContentViewTests.swift
Added tests for resolveNostrMentions() (wrapping bare references, skipping existing links and code blocks, handling multiple mentions and varying display text) and sanitizeUnicodeSeparators() (replacing U+2028/U+2029 and sequential cases). Duplicate test blocks appear in the diff and should be reviewed.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hop through text where hidden mentions play,
I stitch them into links to light the way,
I smooth the separators that break the stream,
Profiles lend names so notes can gleam,
A little rabbit's fix to keep content clean.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the two main changes: rendering nostr: mentions and fixing line breaks in longform articles.
Description check ✅ Passed The description follows the template with comprehensive summary, completed standard checklist, detailed test report with device/iOS specs, and acknowledgment of unrelated test failures.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@alltheseas
Copy link
Copy Markdown
Collaborator Author

commit one

before

image

after

Screenshot 2026-03-04 at 12 58 28 PM

commit two

before

image

after

Screenshot 2026-03-04 at 1 21 27 PM

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
damusTests/NoteContentViewTests.swift (1)

439-499: Good test coverage for the new mention resolution logic.

The tests cover key scenarios: bare mentions, existing links (skip), code blocks (skip), multiple mentions, and display text formatting.

Consider adding tests for:

  • nprofile1 and naddr1 mentions (to verify display text handling for these types)
  • Inline code with single backticks (e.g., `nostr:npub...`) to ensure it's also skipped
📝 Suggested additional test case for inline code
`@MainActor`
func testResolveNostrMentions_insideInlineCode_skipped() {
    let npub = test_pubkey.npub
    let input = "Some `nostr:\(npub)` inline"
    let result = LongformContent.resolveNostrMentions(in: input, profiles: test_damus_state.profiles)

    XCTAssertEqual(result, input, "nostr reference inside inline code should not be modified")
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damusTests/NoteContentViewTests.swift` around lines 439 - 499, Add tests to
cover nprofile1 and naddr1 mention types and inline code with single backticks
so mention resolution skips them; specifically, add new `@MainActor` test methods
(e.g., testResolveNostrMentions_nprofile1DisplayText and
testResolveNostrMentions_naddr1DisplayText) that call
LongformContent.resolveNostrMentions(in:profiles:) with inputs containing
nostr:nprofile1... and nostr:naddr1... and assert the expected display text
formatting and that links are wrapped, and add
testResolveNostrMentions_insideInlineCode_skipped which passes a string
containing `nostr:\(npub)` (single backticks) and asserts the result equals the
input; ensure these tests use the existing fixtures (test_pubkey, test_note,
test_damus_state.profiles) and follow the same assertion style as the other
resolveNostrMentions tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@damusTests/NoteContentViewTests.swift`:
- Around line 439-499: Add tests to cover nprofile1 and naddr1 mention types and
inline code with single backticks so mention resolution skips them;
specifically, add new `@MainActor` test methods (e.g.,
testResolveNostrMentions_nprofile1DisplayText and
testResolveNostrMentions_naddr1DisplayText) that call
LongformContent.resolveNostrMentions(in:profiles:) with inputs containing
nostr:nprofile1... and nostr:naddr1... and assert the expected display text
formatting and that links are wrapped, and add
testResolveNostrMentions_insideInlineCode_skipped which passes a string
containing `nostr:\(npub)` (single backticks) and asserts the result equals the
input; ensure these tests use the existing fixtures (test_pubkey, test_note,
test_damus_state.profiles) and follow the same assertion style as the other
resolveNostrMentions tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 68f0b121-a3e0-4f3a-a0e2-4d74babceff8

📥 Commits

Reviewing files that changed from the base of the PR and between af3bb21 and dfacd12.

📒 Files selected for processing (2)
  • damus/Features/Events/Models/NoteContent.swift
  • damusTests/NoteContentViewTests.swift

alltheseas and others added 3 commits March 4, 2026 13:58
Signed-off-by: alltheseas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 damus-io#1581

Changelog-Fixed: Fixed unexpected line breaks in longform articles

Signed-off-by: alltheseas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ypes

Signed-off-by: alltheseas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alltheseas alltheseas force-pushed the longform-nostr-mentions branch from dfacd12 to 2dd5050 Compare March 4, 2026 20:03
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
damus/Features/Events/Models/NoteContent.swift (1)

388-397: ⚠️ Potential issue | 🟡 Minor

Add a doc comment for the modified initializer.

This initializer now performs multiple preprocessing steps and should be documented for behavior and ordering guarantees.

As per coding guidelines "Ensure docstring coverage for any code added or modified".

🤖 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 388 - 397, Add a
doc comment above the init(_ markdown: String, profiles: Profiles) initializer
describing its behavior and the exact preprocessing order: that it first
sanitizes unicode separators via LongformContent.sanitizeUnicodeSeparators, then
resolves Nostr mentions with LongformContent.resolveNostrMentions(using
profiles), then ensures images are block-level via
LongformContent.ensureBlockLevelImages before parsing into BlockNode and
constructing MarkdownContent and computing words with count_markdown_words;
include notes about why ordering matters, what inputs are expected, and any
guarantees about output (e.g., images being block-level and mentions resolved).
🧹 Nitpick comments (1)
damusTests/NoteContentViewTests.swift (1)

463-469: Add regressions for link-label mentions and markdown-special display names.

Please add cases for nostr: inside markdown link labels (should be skipped) and display names containing []()\\ (should remain well-formed after wrapping). These will guard the most fragile parsing edges.

As per coding guidelines "damusTests/**/*.swift: Add or update unit tests in damusTests/ alongside feature changes, especially when touching parsing, storage, or replay logic".

Also applies to: 492-508

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damusTests/NoteContentViewTests.swift` around lines 463 - 469, Add two unit
test cases in damusTests/NoteContentViewTests.swift next to
testResolveNostrMentions_existingMarkdownLink_skipped: one that asserts a
markdown link label containing "nostr:..." (e.g.,
"[nostr:npub](https://example.com)") is not altered by
LongformContent.resolveNostrMentions (should be skipped), and another that
passes a display name containing markdown-special characters "[]()\\" to
resolveNostrMentions and asserts the output is still well-formed
(wrapping/escaping preserved) to guard parsing edges; use the same test helpers
(test_pubkey, test_damus_state.profiles) and mirror naming convention like
testResolveNostrMentions_markdownLabelSkipped and
testResolveNostrMentions_displayNameWithSpecialChars_preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@damus/Features/Events/Models/NoteContent.swift`:
- Around line 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.
- Around line 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.

---

Outside diff comments:
In `@damus/Features/Events/Models/NoteContent.swift`:
- Around line 388-397: Add a doc comment above the init(_ markdown: String,
profiles: Profiles) initializer describing its behavior and the exact
preprocessing order: that it first sanitizes unicode separators via
LongformContent.sanitizeUnicodeSeparators, then resolves Nostr mentions with
LongformContent.resolveNostrMentions(using profiles), then ensures images are
block-level via LongformContent.ensureBlockLevelImages before parsing into
BlockNode and constructing MarkdownContent and computing words with
count_markdown_words; include notes about why ordering matters, what inputs are
expected, and any guarantees about output (e.g., images being block-level and
mentions resolved).

---

Nitpick comments:
In `@damusTests/NoteContentViewTests.swift`:
- Around line 463-469: Add two unit test cases in
damusTests/NoteContentViewTests.swift next to
testResolveNostrMentions_existingMarkdownLink_skipped: one that asserts a
markdown link label containing "nostr:..." (e.g.,
"[nostr:npub](https://example.com)") is not altered by
LongformContent.resolveNostrMentions (should be skipped), and another that
passes a display name containing markdown-special characters "[]()\\" to
resolveNostrMentions and asserts the output is still well-formed
(wrapping/escaping preserved) to guard parsing edges; use the same test helpers
(test_pubkey, test_damus_state.profiles) and mirror naming convention like
testResolveNostrMentions_markdownLabelSkipped and
testResolveNostrMentions_displayNameWithSpecialChars_preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7dfe5fc9-6a6c-4185-bb70-1132159afbe7

📥 Commits

Reviewing files that changed from the base of the PR and between dfacd12 and 2dd5050.

📒 Files selected for processing (2)
  • damus/Features/Events/Models/NoteContent.swift
  • damusTests/NoteContentViewTests.swift

Comment on lines +569 to +582
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
}
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.

Comment on lines +588 to +600
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))")
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant