input: preserve mention links when inserting text before them
Previously, inserting text right before a mention (@user) would remove the link attribute, breaking the mention. This was because the intersection check in shouldChangeTextIn would trigger and remove the link for any edit that touched the link boundary. Added a new condition to handle insertion at the left edge of a link separately, similar to the existing handling for the right edge. This allows users to type before a mention without breaking it. Added UI test that creates a real mention via autocomplete selection, then verifies text can be typed before it without corrupting the mention. The test uses predicate-based waits for reliability and properly marks the UserView as an accessibility element. Link attribute preservation is verified in unit tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Changelog-Fixed: Fixed mentions unlinking when typing text before them Closes: https://github.com/damus-io/damus/pull/3473 Closes: https://github.com/damus-io/damus/issues/3460 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Tested-by: William Casarin <jb55@jb55.com> Signed-off-by: alltheseas <alltheseas@users.noreply.github.com> Reviewed-by: William Casarin <jb55@jb55.com>
This commit is contained in:
committed by
Daniel D’Aquino
parent
4941b502d5
commit
a4ad4960c4
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user