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:
committed by
Daniel D’Aquino
parent
a4ad4960c4
commit
4f401c6ce9
@@ -86,6 +86,7 @@ struct PostView: View {
|
||||
|
||||
@State private var current_placeholder_index = 0
|
||||
@State private var uploadTasks: [Task<Void, Never>] = []
|
||||
@State private var profileFetchTasks: [Pubkey: Task<Void, Never>] = [:]
|
||||
|
||||
let action: PostAction
|
||||
let damus_state: DamusState
|
||||
@@ -113,6 +114,7 @@ struct PostView: View {
|
||||
func cancel() {
|
||||
notify(.post(.cancel))
|
||||
cancelUploadTasks()
|
||||
cancelProfileFetchTasks()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -121,6 +123,103 @@ struct PostView: View {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NSMutableAttributedString>,
|
||||
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)
|
||||
@@ -269,6 +290,56 @@ struct TextViewWrapper: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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(
|
||||
@@ -237,7 +336,7 @@ func checkMentionLinkEditorHandling(
|
||||
if let expectedNewCursorIndex {
|
||||
XCTAssertEqual(newCursorIndex, expectedNewCursorIndex)
|
||||
}
|
||||
}, initialTextSuffix: nil)
|
||||
}, initialTextSuffix: nil, convertMentionRef: nil)
|
||||
let textView = UITextView()
|
||||
textView.attributedText = content
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user