-
Notifications
You must be signed in to change notification settings - Fork 301
Render nostr: mentions and fix line breaks in longform articles #3659
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
alltheseas
wants to merge
4
commits into
damus-io:master
Choose a base branch
from
alltheseas:longform-nostr-mentions
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+175
−3
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
7e3a89d
Render nostr: NIP-27 mentions in longform articles
alltheseas 0fd96d9
Add tests for NIP-27 mention resolution in longform articles
alltheseas 4be0afc
Fix unexpected line breaks from Unicode separators in longform posts
alltheseas 2dd5050
Add tests for Unicode separator sanitization and additional mention t…
alltheseas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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) | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Escape mention display text before injecting it into markdown link text.
💡 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 |
||
| } | ||
|
|
||
| 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 { | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Existing-link detection is too narrow and can produce nested markdown links.
Current skip logic only checks whether the match is preceded by
"](", sonostr: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
🤖 Prompt for AI Agents