input: convert pasted npub/nprofile to mention with async profile fetch

When pasting an npub or nprofile into the post composer, automatically
convert it to a human-readable mention link. If the profile isn't
cached locally, fetch it from relays and update the mention display
name when it arrives.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changelog-Added: Added automatic conversion of pasted npub/nprofile to human-readable mentions in post composer
Closes: https://github.com/damus-io/damus/issues/2289
Closes: https://github.com/damus-io/damus/pull/3473
Co-Authored-By: Claude Opus 4.5
Tested-by: William Casarin <jb55@jb55.com>
Signed-off-by: alltheseas
Reviewed-by: William Casarin <jb55@jb55.com>
This commit is contained in:
alltheseas
2025-12-26 17:16:07 -06:00
committed by Daniel D’Aquino
parent a4ad4960c4
commit 4f401c6ce9
4 changed files with 410 additions and 5 deletions

View File

@@ -86,6 +86,7 @@ struct PostView: View {
@State private var current_placeholder_index = 0 @State private var current_placeholder_index = 0
@State private var uploadTasks: [Task<Void, Never>] = [] @State private var uploadTasks: [Task<Void, Never>] = []
@State private var profileFetchTasks: [Pubkey: Task<Void, Never>] = [:]
let action: PostAction let action: PostAction
let damus_state: DamusState let damus_state: DamusState
@@ -113,14 +114,112 @@ struct PostView: View {
func cancel() { func cancel() {
notify(.post(.cancel)) notify(.post(.cancel))
cancelUploadTasks() cancelUploadTasks()
cancelProfileFetchTasks()
dismiss() dismiss()
} }
func cancelUploadTasks() { func cancelUploadTasks() {
uploadTasks.forEach { $0.cancel() } uploadTasks.forEach { $0.cancel() }
uploadTasks.removeAll() uploadTasks.removeAll()
} }
/// Cancels all pending profile fetch tasks.
/// Called when the composer is dismissed to prevent background updates to a gone view.
func cancelProfileFetchTasks() {
profileFetchTasks.values.forEach { $0.cancel() }
profileFetchTasks.removeAll()
}
// MARK: - Async Profile Fetch for Pasted npub/nprofile (Issue #2289)
//
// When a user pastes an npub or nprofile, we immediately create a mention link.
// If the profile isn't in the local cache, the mention initially shows "@npub1abc...xyz".
// We then fetch the profile from relays asynchronously and update the mention text
// to show the human-readable name (e.g., "@jack") when it arrives.
/// Fetches a profile from relays and updates any mentions in the post when it arrives.
///
/// This enables pasted npub/nprofile identifiers to resolve to human-readable names
/// even when the profile isn't in the local nostrdb cache. Uses `streamProfile` which
/// queries relays and yields the profile when found.
///
/// - Parameter pubkey: The public key to fetch the profile for
func fetchProfileAndUpdateMention(pubkey: Pubkey) {
// Avoid duplicate fetches for the same pubkey
guard profileFetchTasks[pubkey] == nil else { return }
let task = Task {
// streamProfile yields profiles as they arrive from relays
// yieldCached: false since we already checked the cache before calling this
for await profile in await damus_state.nostrNetwork.profilesManager.streamProfile(pubkey: pubkey, yieldCached: false) {
await MainActor.run {
updateMentionDisplayName(for: pubkey, profile: profile)
}
// Only need the first profile update
break
}
await MainActor.run {
profileFetchTasks.removeValue(forKey: pubkey)
}
}
profileFetchTasks[pubkey] = task
}
/// Updates the display text for mentions matching the given pubkey.
///
/// Finds all mention links with the matching `damus:nostr:` URL scheme and replaces
/// the abbreviated "@npub1..." or "@nprofile1..." text with the resolved profile name.
/// Preserves all existing attributes (link, styling) on the mention.
///
/// Uses a two-pass approach to avoid undefined behavior from mutating while enumerating:
/// 1. First pass: collect all matching ranges and their attributes
/// 2. Second pass: replace ranges in reverse order to maintain valid indices
///
/// - Parameters:
/// - pubkey: The public key whose mentions should be updated
/// - profile: The fetched profile containing the display name
func updateMentionDisplayName(for pubkey: Pubkey, profile: Profile?) {
let linkURL = "damus:nostr:\(pubkey.npub)"
let newDisplayName = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
let newTagString = "@\(newDisplayName)"
let mutablePost = NSMutableAttributedString(attributedString: post)
// Pass 1: Collect matching ranges (avoid mutating while enumerating)
var rangesToUpdate: [(range: NSRange, attrs: [NSAttributedString.Key: Any])] = []
mutablePost.enumerateAttribute(.link, in: NSRange(location: 0, length: mutablePost.length), options: []) { value, range, _ in
// Extract link URL from either String or URL type
let linkValue = (value as? String) ?? (value as? URL)?.absoluteString
guard linkValue == linkURL else { return }
// Only update if still showing abbreviated form (not already resolved)
let currentText = mutablePost.attributedSubstring(from: range).string
guard currentText.hasPrefix("@npub") || currentText.hasPrefix("@nprofile") else { return }
// Preserve all attributes from the original range
var collectedAttrs: [NSAttributedString.Key: Any] = [:]
mutablePost.enumerateAttributes(in: range, options: []) { attrs, _, _ in
collectedAttrs.merge(attrs) { _, new in new }
}
rangesToUpdate.append((range: range, attrs: collectedAttrs))
}
guard !rangesToUpdate.isEmpty else { return }
// Pass 2: Replace in reverse order so earlier indices remain valid
for (range, attrs) in rangesToUpdate.reversed() {
let newAttrString = NSMutableAttributedString(string: newTagString)
newAttrString.addAttributes(attrs, range: NSRange(location: 0, length: newAttrString.length))
mutablePost.replaceCharacters(in: range, with: newAttrString)
}
// Update post without cursor adjustment - async updates shouldn't move user's cursor
post = mutablePost
}
func send_post() async { func send_post() async {
let new_post = await build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys) let new_post = await build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys)
@@ -279,6 +378,15 @@ struct PostView: View {
}, },
updateCursorPosition: { newCursorIndex in updateCursorPosition: { newCursorIndex in
self.newCursorIndex = newCursorIndex self.newCursorIndex = newCursorIndex
},
convertMentionRef: { pubkey in
let profile = try? damus_state.profiles.lookup(id: pubkey)
if profile == nil {
fetchProfileAndUpdateMention(pubkey: pubkey)
}
return user_tag_attr_string(profile: profile, pubkey: pubkey)
} }
) )
.environmentObject(tagModel) .environmentObject(tagModel)
@@ -575,6 +683,7 @@ struct PostView: View {
clear_draft() clear_draft()
} }
preUploadedMedia.removeAll() preUploadedMedia.removeAll()
cancelProfileFetchTasks()
} }
} }
} }

View File

@@ -18,6 +18,7 @@ struct TextViewWrapper: UIViewRepresentable {
let cursorIndex: Int? let cursorIndex: Int?
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
let updateCursorPosition: ((Int) -> Void) let updateCursorPosition: ((Int) -> Void)
var convertMentionRef: ((Pubkey) -> NSMutableAttributedString?)? = nil
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let textView = CustomPostTextView(imagePastedFromPasteboard: $imagePastedFromPasteboard, let textView = CustomPostTextView(imagePastedFromPasteboard: $imagePastedFromPasteboard,
@@ -97,7 +98,7 @@ struct TextViewWrapper: UIViewRepresentable {
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition, initialTextSuffix: initialTextSuffix) Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition, initialTextSuffix: initialTextSuffix, convertMentionRef: convertMentionRef)
} }
class Coordinator: NSObject, UITextViewDelegate { class Coordinator: NSObject, UITextViewDelegate {
@@ -106,17 +107,20 @@ struct TextViewWrapper: UIViewRepresentable {
let updateCursorPosition: ((Int) -> Void) let updateCursorPosition: ((Int) -> Void)
let initialTextSuffix: String? let initialTextSuffix: String?
var initialTextSuffixWasAdded: Bool = false var initialTextSuffixWasAdded: Bool = false
var convertMentionRef: ((Pubkey) -> NSMutableAttributedString?)? = nil
static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; ", "#"] static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; ", "#"]
init(attributedText: Binding<NSMutableAttributedString>, init(attributedText: Binding<NSMutableAttributedString>,
getFocusWordForMention: ((String?, NSRange?) -> Void)?, getFocusWordForMention: ((String?, NSRange?) -> Void)?,
updateCursorPosition: @escaping ((Int) -> Void), updateCursorPosition: @escaping ((Int) -> Void),
initialTextSuffix: String? initialTextSuffix: String?,
convertMentionRef: ((Pubkey) -> NSMutableAttributedString?)?
) { ) {
_attributedText = attributedText _attributedText = attributedText
self.getFocusWordForMention = getFocusWordForMention self.getFocusWordForMention = getFocusWordForMention
self.updateCursorPosition = updateCursorPosition self.updateCursorPosition = updateCursorPosition
self.initialTextSuffix = initialTextSuffix self.initialTextSuffix = initialTextSuffix
self.convertMentionRef = convertMentionRef
} }
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
@@ -210,6 +214,23 @@ struct TextViewWrapper: UIViewRepresentable {
guard let attributedString = textView.attributedText else { guard let attributedString = textView.attributedText else {
return true // If we cannot get an attributed string, just fail gracefully and allow changes return true // If we cannot get an attributed string, just fail gracefully and allow changes
} }
// MARK: npub/nprofile Paste Handling (Issue #2289)
// When user pastes an npub or nprofile string, convert it to a mention link.
// This creates an attributed string with the damus:nostr: URL scheme that
// renders as a tappable @mention. If the profile isn't cached, PostView
// will trigger an async fetch to resolve the display name.
if let mentionTag = convertPastedNostrIdentifier(text) {
var mutable = NSMutableAttributedString(attributedString: attributedString)
// Add leading space if pasting immediately after non-whitespace
let paddedTag = pad_attr_string(tag: mentionTag, before: shouldPrepadMention(post: mutable, insertLocation: range.location))
mutable.replaceCharacters(in: range, with: paddedTag)
attributedText = mutable
// Position cursor after the inserted mention
updateCursorPosition(range.location + paddedTag.length)
return false
}
var mutable = NSMutableAttributedString(attributedString: attributedString) var mutable = NSMutableAttributedString(attributedString: attributedString)
let entireRange = NSRange(location: 0, length: attributedString.length) let entireRange = NSRange(location: 0, length: attributedString.length)
@@ -268,6 +289,56 @@ struct TextViewWrapper: UIViewRepresentable {
attributedString.replaceCharacters(in: range, with: text) attributedString.replaceCharacters(in: range, with: text)
} }
} }
// MARK: - npub/nprofile Conversion Helpers
/// Converts a pasted npub or nprofile bech32 string to a mention attributed string.
///
/// Handles both `npub1...` (public key) and `nprofile1...` (profile with optional relays)
/// formats. Uses `Bech32Object.parse` to decode and extract the public key.
///
/// - Parameter text: The pasted text to check and convert
/// - Returns: An attributed mention string if valid npub/nprofile, nil otherwise
private func convertPastedNostrIdentifier(_ text: String) -> NSMutableAttributedString? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
// Early return if not a nostr identifier
guard trimmed.hasPrefix("npub1") || trimmed.hasPrefix("nprofile1") else {
return nil
}
// Parse bech32 and extract pubkey
guard let parsed = Bech32Object.parse(trimmed),
let pubkey = parsed.pubkey() else {
return nil
}
// Convert to mention using the callback provided by PostView
return convertMentionRef?(pubkey)
}
/// Determines if a space should be inserted before a mention at the given location.
///
/// When pasting an npub immediately after text (e.g., "Hello{paste}"), we need
/// to add a leading space so it renders as "Hello @mention" not "Hello@mention".
///
/// - Parameters:
/// - post: The current post attributed string
/// - insertLocation: The index where the mention will be inserted
/// - Returns: true if a space should be prepended, false otherwise
private func shouldPrepadMention(post: NSMutableAttributedString, insertLocation: Int) -> Bool {
// No prepad needed at document start
guard insertLocation > 0 else { return false }
let precedingRange = NSRange(location: insertLocation - 1, length: 1)
let precedingChar = post.attributedSubstring(from: precedingRange).string.first
// No prepad if we can't read the character
guard let char = precedingChar else { return false }
// Prepad if preceding character is not whitespace or newline
return !char.isWhitespace && !char.isNewline
}
} }
} }

View File

@@ -219,6 +219,105 @@ final class PostViewTests: XCTestCase {
// then // then
XCTAssertEqual(post.content, "nostr:\(test_pubkey.npub)") XCTAssertEqual(post.content, "nostr:\(test_pubkey.npub)")
} }
/// Tests that pasting an npub converts it to a mention link (issue #2289)
func testPastedNpubConvertsToMention() {
let content = NSMutableAttributedString(string: "Hello ")
var resultContent: NSMutableAttributedString?
let bindingContent: Binding<NSMutableAttributedString> = Binding(get: {
return content
}, set: { newValue in
resultContent = newValue
})
let coordinator = TextViewWrapper.Coordinator(
attributedText: bindingContent,
getFocusWordForMention: nil,
updateCursorPosition: { _ in },
initialTextSuffix: nil,
convertMentionRef: { pubkey in
// Return a mock mention tag
return NSMutableAttributedString(string: "@testuser", attributes: [
NSAttributedString.Key.link: "damus:nostr:\(pubkey.npub)"
])
}
)
let textView = UITextView()
textView.attributedText = content
// Paste an npub - should return false (handled manually) and convert to mention
let npub = test_pubkey.npub
let shouldChange = coordinator.textView(textView, shouldChangeTextIn: NSRange(location: 6, length: 0), replacementText: npub)
XCTAssertFalse(shouldChange, "shouldChangeTextIn should return false when converting npub to mention")
XCTAssertNotNil(resultContent, "Content should be updated with mention")
XCTAssertTrue(resultContent?.string.contains("@testuser") ?? false, "Content should contain the mention username")
}
/// Tests that pasting an nprofile converts it to a mention link (issue #2289)
func testPastedNprofileConvertsToMention() {
let content = NSMutableAttributedString(string: "")
var resultContent: NSMutableAttributedString?
let bindingContent: Binding<NSMutableAttributedString> = Binding(get: {
return content
}, set: { newValue in
resultContent = newValue
})
let coordinator = TextViewWrapper.Coordinator(
attributedText: bindingContent,
getFocusWordForMention: nil,
updateCursorPosition: { _ in },
initialTextSuffix: nil,
convertMentionRef: { pubkey in
return NSMutableAttributedString(string: "@profileuser", attributes: [
NSAttributedString.Key.link: "damus:nostr:\(pubkey.npub)"
])
}
)
let textView = UITextView()
textView.attributedText = content
// Create a valid nprofile for test_pubkey
let nprofile = Bech32Object.encode(.nprofile(NProfile(author: test_pubkey, relays: [])))
let shouldChange = coordinator.textView(textView, shouldChangeTextIn: NSRange(location: 0, length: 0), replacementText: nprofile)
XCTAssertFalse(shouldChange, "shouldChangeTextIn should return false when converting nprofile to mention")
XCTAssertNotNil(resultContent, "Content should be updated with mention")
XCTAssertTrue(resultContent?.string.contains("@profileuser") ?? false, "Content should contain the mention username")
}
/// Tests that regular text is not converted (not npub/nprofile)
func testRegularTextNotConverted() {
let content = NSMutableAttributedString(string: "Hello ")
let bindingContent: Binding<NSMutableAttributedString> = Binding(get: {
return content
}, set: { _ in })
let coordinator = TextViewWrapper.Coordinator(
attributedText: bindingContent,
getFocusWordForMention: nil,
updateCursorPosition: { _ in },
initialTextSuffix: nil,
convertMentionRef: { _ in
XCTFail("convertMentionRef should not be called for regular text")
return nil
}
)
let textView = UITextView()
textView.attributedText = content
// Paste regular text - should return true (not handled, allow default behavior)
let shouldChange = coordinator.textView(textView, shouldChangeTextIn: NSRange(location: 6, length: 0), replacementText: "world")
XCTAssertTrue(shouldChange, "shouldChangeTextIn should return true for regular text")
}
} }
func checkMentionLinkEditorHandling( func checkMentionLinkEditorHandling(
@@ -237,7 +336,7 @@ func checkMentionLinkEditorHandling(
if let expectedNewCursorIndex { if let expectedNewCursorIndex {
XCTAssertEqual(newCursorIndex, expectedNewCursorIndex) XCTAssertEqual(newCursorIndex, expectedNewCursorIndex)
} }
}, initialTextSuffix: nil) }, initialTextSuffix: nil, convertMentionRef: nil)
let textView = UITextView() let textView = UITextView()
textView.attributedText = content textView.attributedText = content

View File

@@ -6,6 +6,7 @@
// //
import XCTest import XCTest
import UIKit
class damusUITests: XCTestCase { class damusUITests: XCTestCase {
var app = XCUIApplication() var app = XCUIApplication()
@@ -293,6 +294,131 @@ class damusUITests: XCTestCase {
app.buttons[AID.post_composer_cancel_button.rawValue].tap() app.buttons[AID.post_composer_cancel_button.rawValue].tap()
} }
/// Tests that pasting an npub into the post composer converts it to a mention
/// and resolves to a human-readable profile name via async fetch.
/// This guards against regressions in https://github.com/damus-io/damus/issues/2289
func testPastedNpubResolvesToProfileName() throws {
try self.loginIfNotAlready()
// Set up interruption handler for iOS paste permission alerts
// iOS 16+ may show "Allow Paste" system alerts when pasting from other apps
addUIInterruptionMonitor(withDescription: "Paste Permission Alert") { alert in
// Handle both English and common localizations of the "Allow Paste" button
let allowButtons = ["Allow Paste", "Paste", "Allow", "Erlauben", "Autoriser", "許可"]
for buttonLabel in allowButtons {
let button = alert.buttons[buttonLabel]
if button.exists {
button.tap()
return true
}
}
// Try first button as fallback (typically the "allow" action)
if alert.buttons.count > 0 {
alert.buttons.element(boundBy: 0).tap()
return true
}
return false
}
// Open post composer
guard app.buttons[AID.post_button.rawValue].waitForExistence(timeout: 10) else {
throw DamusUITestError.timeout_waiting_for_element
}
app.buttons[AID.post_button.rawValue].tap()
guard app.textViews[AID.post_composer_text_view.rawValue].waitForExistence(timeout: 5) else {
throw DamusUITestError.timeout_waiting_for_element
}
let textView = app.textViews[AID.post_composer_text_view.rawValue]
textView.tap()
// Use a well-known npub (jack dorsey) that should resolve to a profile name
let testNpub = "npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m"
// Put npub in pasteboard
UIPasteboard.general.string = testNpub
// Long press to bring up paste menu
textView.press(forDuration: 1.0)
// Find paste menu item - handle localized variants
// iOS uses "Paste" in English but varies by locale
let pasteLabels = ["Paste", "Einfügen", "Coller", "Pegar", "Incolla", "ペースト", "貼り付け", "붙여넣기"]
var pasteButton: XCUIElement?
for label in pasteLabels {
let button = app.menuItems[label]
if button.waitForExistence(timeout: 0.5) {
pasteButton = button
break
}
}
guard let pasteButton = pasteButton else {
// Fallback: try first menu item if no known paste label found
let firstMenuItem = app.menuItems.firstMatch
if firstMenuItem.waitForExistence(timeout: 1) {
firstMenuItem.tap()
} else {
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
throw XCTSkip("Paste menu not available in this environment")
}
// Trigger interruption monitors by interacting with app
app.tap()
// Check if paste worked despite not finding the button
let checkText = textView.value as? String ?? ""
if !checkText.contains("@") && !checkText.contains("npub") {
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
throw XCTSkip("Could not trigger paste action")
}
// Paste worked via fallback - clean up and exit
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
return
}
pasteButton.tap()
// Trigger interruption monitors in case paste permission alert appeared
app.tap()
// Wait for initial mention to appear (should contain @ symbol)
let mentionAppearedPredicate = NSPredicate(format: "value CONTAINS[c] '@'")
let mentionAppeared = expectation(for: mentionAppearedPredicate, evaluatedWith: textView)
wait(for: [mentionAppeared], timeout: 5)
// Verify initial paste created a mention (may still show @npub... initially)
let initialText = textView.value as? String ?? ""
XCTAssertTrue(initialText.contains("@"),
"Pasted npub should create a mention but text was '\(initialText)'")
// Wait for async profile fetch to resolve the name (should NOT contain "npub1" after resolution)
// Give it up to 10 seconds for relay fetch
let profileResolvedPredicate = NSPredicate(format: "NOT (value CONTAINS[c] 'npub1')")
let profileResolved = expectation(for: profileResolvedPredicate, evaluatedWith: textView)
let result = XCTWaiter.wait(for: [profileResolved], timeout: 10)
let finalText = textView.value as? String ?? ""
if result == .timedOut {
// Profile didn't resolve - this could happen if offline or relay issues
// Still verify the npub was at least converted to a mention link
XCTAssertTrue(finalText.contains("@"),
"Text should contain a mention but was '\(finalText)'")
print("Note: Profile did not resolve within timeout. Text: '\(finalText)'")
} else {
// Profile resolved - verify it's a human-readable name
XCTAssertTrue(finalText.contains("@"),
"Text should contain a mention but was '\(finalText)'")
XCTAssertFalse(finalText.contains("npub1"),
"Mention should resolve to profile name, not show npub. Text: '\(finalText)'")
}
// Cancel to clean up
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
}
enum DamusUITestError: Error { enum DamusUITestError: Error {
case timeout_waiting_for_element case timeout_waiting_for_element
} }