diff --git a/damus/AppAccessibilityIdentifiers.swift b/damus/AppAccessibilityIdentifiers.swift index dfbd0b11..293c63ea 100644 --- a/damus/AppAccessibilityIdentifiers.swift +++ b/damus/AppAccessibilityIdentifiers.swift @@ -60,9 +60,19 @@ enum AppAccessibilityIdentifiers: String { // MARK: Post composer // Prefix: `post_composer` - + /// The cancel post button case post_composer_cancel_button + + /// The text view where the user types their note + case post_composer_text_view + + + // MARK: Post button (FAB) + // Prefix: `post_button` + + /// The floating action button to create a new post + case post_button // MARK: Main interface layout // Prefix: `main` diff --git a/damus/Features/Posting/Views/PostView.swift b/damus/Features/Posting/Views/PostView.swift index 42357bdd..647d9eeb 100644 --- a/damus/Features/Posting/Views/PostView.swift +++ b/damus/Features/Posting/Views/PostView.swift @@ -276,7 +276,7 @@ struct PostView: View { getFocusWordForMention: { word, range in focusWordAttributes = (word, range) self.newCursorIndex = nil - }, + }, updateCursorPosition: { newCursorIndex in self.newCursorIndex = newCursorIndex } @@ -284,6 +284,7 @@ struct PostView: View { .environmentObject(tagModel) .focused($focus) .textInputAutocapitalization(.sentences) + .accessibilityIdentifier(AppAccessibilityIdentifiers.post_composer_text_view.rawValue) .onChange(of: post) { p in post_changed(post: p, media: uploadedMedias) } diff --git a/damus/Shared/Buttons/PostButton.swift b/damus/Shared/Buttons/PostButton.swift index c0e066c3..b1b88d35 100644 --- a/damus/Shared/Buttons/PostButton.swift +++ b/damus/Shared/Buttons/PostButton.swift @@ -31,6 +31,7 @@ func PostButton(action: @escaping () -> ()) -> some View { .foregroundColor(Color.white) } }) + .accessibilityIdentifier(AppAccessibilityIdentifiers.post_button.rawValue) .keyboardShortcut("n", modifiers: [.command, .shift]) } diff --git a/damus/Shared/Components/Text/TextViewWrapper.swift b/damus/Shared/Components/Text/TextViewWrapper.swift index 5e9e925d..30307ff7 100644 --- a/damus/Shared/Components/Text/TextViewWrapper.swift +++ b/damus/Shared/Components/Text/TextViewWrapper.swift @@ -120,6 +120,7 @@ struct TextViewWrapper: UIViewRepresentable { } func textViewDidChange(_ textView: UITextView) { + // Handle one-time initial text suffix insertion (e.g., for replies) if let initialTextSuffix, !self.initialTextSuffixWasAdded { self.initialTextSuffixWasAdded = true var mutable = NSMutableAttributedString(attributedString: textView.attributedText) @@ -129,11 +130,19 @@ struct TextViewWrapper: UIViewRepresentable { DispatchQueue.main.async { self.updateCursorPosition(originalRange.location) } + processFocusedWordForMention(textView: textView) + return } - else { - attributedText = NSMutableAttributedString(attributedString: textView.attributedText) - } + + attributedText = NSMutableAttributedString(attributedString: textView.attributedText) processFocusedWordForMention(textView: textView) + + // Fix for cursor jumping to position 0 after typing first character. + // When text changes, multiple SwiftUI view updates can occur (text change, + // placeholder removal, height change). The getFocusWordForMention callback + // sets newCursorIndex to nil, forcing reliance on savedRange which may be + // stale. Explicitly tracking cursor position here ensures correct restoration. + updateCursorPosition(textView.selectedRange.location) } private func processFocusedWordForMention(textView: UITextView) { diff --git a/damusUITests/damusUITests.swift b/damusUITests/damusUITests.swift index 46e07bb6..574512de 100644 --- a/damusUITests/damusUITests.swift +++ b/damusUITests/damusUITests.swift @@ -188,6 +188,43 @@ class damusUITests: XCTestCase { guard app.buttons[AID.sign_in_confirm_button.rawValue].tapIfExists(timeout: 5) else { throw DamusUITestError.timeout_waiting_for_element } } + /// Tests that typing in the post composer works correctly, specifically that + /// the cursor position is maintained after typing each character. + /// This guards against regressions like https://github.com/damus-io/damus/issues/3461 + /// where the cursor would jump to position 0 after typing the first character. + func testPostComposerCursorPosition() throws { + try self.loginIfNotAlready() + + // Wait for main interface to load, then tap the post button (FAB) + guard app.buttons[AID.post_button.rawValue].waitForExistence(timeout: 10) else { + throw DamusUITestError.timeout_waiting_for_element + } + app.buttons[AID.post_button.rawValue].tap() + + // Wait for the post composer text view to appear + 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 a test string character by character + // If the cursor jumps to position 0 after the first character, + // the resulting text would be scrambled (e.g., "olleH" instead of "Hello") + let testString = "Hello" + textView.typeText(testString) + + // Verify the text was typed correctly (not scrambled) + let actualText = textView.value as? String ?? "" + XCTAssertEqual(actualText, testString, + "Text should be '\(testString)' but was '\(actualText)'. " + + "This may indicate a cursor position bug.") + + // Cancel the post to clean up + app.buttons[AID.post_composer_cancel_button.rawValue].tap() + } + enum DamusUITestError: Error { case timeout_waiting_for_element }