diff --git a/damus/AppAccessibilityIdentifiers.swift b/damus/AppAccessibilityIdentifiers.swift index 293c63ea..9b4fd4ad 100644 --- a/damus/AppAccessibilityIdentifiers.swift +++ b/damus/AppAccessibilityIdentifiers.swift @@ -67,6 +67,9 @@ enum AppAccessibilityIdentifiers: String { /// The text view where the user types their note case post_composer_text_view + /// A user result in the mention autocomplete list + case post_composer_mention_user_result + // MARK: Post button (FAB) // Prefix: `post_button` diff --git a/damus/Features/Posting/Views/UserSearch.swift b/damus/Features/Posting/Views/UserSearch.swift index bb129b94..a35383a6 100644 --- a/damus/Features/Posting/Views/UserSearch.swift +++ b/damus/Features/Posting/Views/UserSearch.swift @@ -51,6 +51,8 @@ struct UserSearch: View { ForEach(users) { pk in UserView(damus_state: damus_state, pubkey: pk) .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityIdentifier(AppAccessibilityIdentifiers.post_composer_mention_user_result.rawValue) .onTapGesture { on_user_tapped(pk: pk) } diff --git a/damus/Shared/Components/Text/TextViewWrapper.swift b/damus/Shared/Components/Text/TextViewWrapper.swift index 30307ff7..c4e93999 100644 --- a/damus/Shared/Components/Text/TextViewWrapper.swift +++ b/damus/Shared/Components/Text/TextViewWrapper.swift @@ -226,6 +226,13 @@ struct TextViewWrapper: UIViewRepresentable { // This link will naturally disappear, so no work needs to be done in this range. return } + else if range.location == linkRange.location && range.length == 0 { + // Inserting at the left edge of a link. Handle manually to prevent the + // new character from becoming part of the link, but preserve the link. + // This allows users to type before a mention without breaking it. + performEditActionManually = true + return + } else if linkRange.intersection(range) != nil { // If user tries to change an existing link directly, remove the link attribute mutable.removeAttribute(.link, range: linkRange) diff --git a/damusTests/PostViewTests.swift b/damusTests/PostViewTests.swift index 8b7f4e5a..aa9400ef 100644 --- a/damusTests/PostViewTests.swift +++ b/damusTests/PostViewTests.swift @@ -107,12 +107,13 @@ final class PostViewTests: XCTestCase { XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 6, effectiveRange: nil)) }) - // Test adding text right at the start of a mention link, to check that the link is removed + // Test adding text right at the start of a mention link - link should be preserved content = NSMutableAttributedString(string: "Hello @user") content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 5)) checkMentionLinkEditorHandling(content: content, replacementText: "a", replacementRange: NSRange(location: 6, length: 0), shouldBeAbleToChangeAutomatically: false, expectedNewCursorIndex: 7, handleNewContent: { newManuallyEditedContent in XCTAssertEqual(newManuallyEditedContent.string, "Hello a@user") - XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 7, effectiveRange: nil)) + // Link should be preserved and shifted to position 7 (after the inserted "a") + XCTAssertNotNil(newManuallyEditedContent.attribute(.link, at: 7, effectiveRange: nil)) }) // Test that removing one link does not affect the other diff --git a/damusUITests/damusUITests.swift b/damusUITests/damusUITests.swift index 574512de..9558c46c 100644 --- a/damusUITests/damusUITests.swift +++ b/damusUITests/damusUITests.swift @@ -225,6 +225,74 @@ class damusUITests: XCTestCase { app.buttons[AID.post_composer_cancel_button.rawValue].tap() } + /// Tests that typing before a mention doesn't break the mention. + /// This guards against regressions like https://github.com/damus-io/damus/issues/3460 + /// where inserting text before a mention would unlink it. + /// + /// The test creates a real mention link by selecting from autocomplete, + /// then types text before it and verifies the mention text is preserved. + /// Note: Link attribute preservation is verified in unit tests (PostViewTests). + func testTypingBeforeMentionPreservesMention() throws { + try self.loginIfNotAlready() + + // 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() + + // Type "@" to trigger mention autocomplete + textView.typeText("@") + + // Wait for autocomplete results to appear and tap on a user to create a real mention link + let mentionResult = app.otherElements[AID.post_composer_mention_user_result.rawValue].firstMatch + guard mentionResult.waitForExistence(timeout: 5) else { + // If no autocomplete results (no contacts loaded), skip this test gracefully + app.buttons[AID.post_composer_cancel_button.rawValue].tap() + throw XCTSkip("No mention autocomplete results available - contacts may not be loaded") + } + mentionResult.tap() + + // Wait for mention to be inserted (text should contain more than just "@") + let mentionInsertedPredicate = NSPredicate(format: "value CONTAINS[c] '@' AND value.length > 1") + let mentionInserted = expectation(for: mentionInsertedPredicate, evaluatedWith: textView) + wait(for: [mentionInserted], timeout: 3) + + // Get the current text which should contain the mention (e.g., "@username ") + let textAfterMention = textView.value as? String ?? "" + XCTAssertTrue(textAfterMention.contains("@"), + "Text should contain a mention after selection but was '\(textAfterMention)'") + + // Move cursor to the beginning and type a prefix + let startCoordinate = textView.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.5)) + startCoordinate.tap() + + // Type prefix text before the mention + textView.typeText("Hey ") + + // Wait for the prefix to be inserted + let prefixInsertedPredicate = NSPredicate(format: "value BEGINSWITH 'Hey '") + let prefixInserted = expectation(for: prefixInsertedPredicate, evaluatedWith: textView) + wait(for: [prefixInserted], timeout: 3) + + // Verify the text contains both the prefix and the mention is preserved + let finalText = textView.value as? String ?? "" + XCTAssertTrue(finalText.hasPrefix("Hey "), + "Text should start with 'Hey ' but was '\(finalText)'") + XCTAssertTrue(finalText.contains("@"), + "Text should still contain the mention '@' but was '\(finalText)'") + + // Cancel to clean up + app.buttons[AID.post_composer_cancel_button.rawValue].tap() + } + enum DamusUITestError: Error { case timeout_waiting_for_element }