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 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user