From 4f401c6ce939ff239431e06eabf46d77afb1da3d Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 26 Dec 2025 17:16:07 -0600 Subject: [PATCH] input: convert pasted npub/nprofile to mention with async profile fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: alltheseas Reviewed-by: William Casarin --- damus/Features/Posting/Views/PostView.swift | 113 +++++++++++++++- .../Components/Text/TextViewWrapper.swift | 75 ++++++++++- damusTests/PostViewTests.swift | 101 +++++++++++++- damusUITests/damusUITests.swift | 126 ++++++++++++++++++ 4 files changed, 410 insertions(+), 5 deletions(-) diff --git a/damus/Features/Posting/Views/PostView.swift b/damus/Features/Posting/Views/PostView.swift index 647d9eeb..de92e1df 100644 --- a/damus/Features/Posting/Views/PostView.swift +++ b/damus/Features/Posting/Views/PostView.swift @@ -86,6 +86,7 @@ struct PostView: View { @State private var current_placeholder_index = 0 @State private var uploadTasks: [Task] = [] + @State private var profileFetchTasks: [Pubkey: Task] = [:] let action: PostAction let damus_state: DamusState @@ -113,14 +114,112 @@ struct PostView: View { func cancel() { notify(.post(.cancel)) cancelUploadTasks() + cancelProfileFetchTasks() dismiss() } - + func cancelUploadTasks() { uploadTasks.forEach { $0.cancel() } 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 { 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 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) @@ -575,6 +683,7 @@ struct PostView: View { clear_draft() } preUploadedMedia.removeAll() + cancelProfileFetchTasks() } } } diff --git a/damus/Shared/Components/Text/TextViewWrapper.swift b/damus/Shared/Components/Text/TextViewWrapper.swift index c4e93999..ad5f1114 100644 --- a/damus/Shared/Components/Text/TextViewWrapper.swift +++ b/damus/Shared/Components/Text/TextViewWrapper.swift @@ -18,6 +18,7 @@ struct TextViewWrapper: UIViewRepresentable { let cursorIndex: Int? var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil let updateCursorPosition: ((Int) -> Void) + var convertMentionRef: ((Pubkey) -> NSMutableAttributedString?)? = nil func makeUIView(context: Context) -> UITextView { let textView = CustomPostTextView(imagePastedFromPasteboard: $imagePastedFromPasteboard, @@ -97,7 +98,7 @@ struct TextViewWrapper: UIViewRepresentable { } 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 { @@ -106,17 +107,20 @@ struct TextViewWrapper: UIViewRepresentable { let updateCursorPosition: ((Int) -> Void) let initialTextSuffix: String? var initialTextSuffixWasAdded: Bool = false + var convertMentionRef: ((Pubkey) -> NSMutableAttributedString?)? = nil static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; ", "#"] init(attributedText: Binding, getFocusWordForMention: ((String?, NSRange?) -> Void)?, updateCursorPosition: @escaping ((Int) -> Void), - initialTextSuffix: String? + initialTextSuffix: String?, + convertMentionRef: ((Pubkey) -> NSMutableAttributedString?)? ) { _attributedText = attributedText self.getFocusWordForMention = getFocusWordForMention self.updateCursorPosition = updateCursorPosition self.initialTextSuffix = initialTextSuffix + self.convertMentionRef = convertMentionRef } func textViewDidChange(_ textView: UITextView) { @@ -210,6 +214,23 @@ struct TextViewWrapper: UIViewRepresentable { guard let attributedString = textView.attributedText else { 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) let entireRange = NSRange(location: 0, length: attributedString.length) @@ -268,6 +289,56 @@ struct TextViewWrapper: UIViewRepresentable { 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 + } } } diff --git a/damusTests/PostViewTests.swift b/damusTests/PostViewTests.swift index aa9400ef..5a3ebb8f 100644 --- a/damusTests/PostViewTests.swift +++ b/damusTests/PostViewTests.swift @@ -219,6 +219,105 @@ final class PostViewTests: XCTestCase { // then 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 = 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 = 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 = 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( @@ -237,7 +336,7 @@ func checkMentionLinkEditorHandling( if let expectedNewCursorIndex { XCTAssertEqual(newCursorIndex, expectedNewCursorIndex) } - }, initialTextSuffix: nil) + }, initialTextSuffix: nil, convertMentionRef: nil) let textView = UITextView() textView.attributedText = content diff --git a/damusUITests/damusUITests.swift b/damusUITests/damusUITests.swift index 9558c46c..8abd0b7c 100644 --- a/damusUITests/damusUITests.swift +++ b/damusUITests/damusUITests.swift @@ -6,6 +6,7 @@ // import XCTest +import UIKit class damusUITests: XCTestCase { var app = XCUIApplication() @@ -293,6 +294,131 @@ class damusUITests: XCTestCase { 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 { case timeout_waiting_for_element }