Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions damus/Shared/Components/Text/TextViewWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ struct TextViewWrapper: UIViewRepresentable {
}

func updateUIView(_ uiView: UITextView, context: Context) {
// The programmatic changes below fire delegate callbacks; suppress selection
// tracking so that only user-driven caret moves update the tracked position
context.coordinator.isApplyingProgrammaticChange = true
defer { context.coordinator.isApplyingProgrammaticChange = false }

// Save the current selection BEFORE making any changes
// This is critical because setting attributedText causes UITextView to reset the cursor position
let savedRange = uiView.selectedRange
Expand Down Expand Up @@ -108,6 +113,9 @@ struct TextViewWrapper: UIViewRepresentable {
let initialTextSuffix: String?
var initialTextSuffixWasAdded: Bool = false
var convertMentionRef: ((Pubkey) -> NSMutableAttributedString?)? = nil
// True while updateUIView applies programmatic text/selection changes,
// whose delegate callbacks must not be recorded as user-driven caret moves
var isApplyingProgrammaticChange: Bool = false
static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; ", "#"]

init(attributedText: Binding<NSMutableAttributedString>,
Expand Down Expand Up @@ -149,6 +157,16 @@ struct TextViewWrapper: UIViewRepresentable {
updateCursorPosition(textView.selectedRange.location)
}

// Keep the tracked cursor position in sync when the user moves the caret
// without editing text (e.g. tapping mid-text). Otherwise the position
// recorded at the last text change goes stale, and the next view update
// (e.g. triggered by an iOS keyboard switch) re-applies it, jumping the
// caret to the end of the text (issue #3545).
func textViewDidChangeSelection(_ textView: UITextView) {
guard !isApplyingProgrammaticChange, textView.selectedRange.length == 0 else { return }
updateCursorPosition(textView.selectedRange.location)
}

private func processFocusedWordForMention(textView: UITextView) {
var val: (String?, NSRange?) = (nil, nil)

Expand Down
36 changes: 36 additions & 0 deletions damusTests/PostViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,42 @@ final class PostViewTests: XCTestCase {
XCTAssertTrue(shouldChange, "shouldChangeTextIn should return true for regular text")
}

/// Tests that moving the caret without editing text updates the tracked cursor
/// position, so a later view update does not restore a stale position (issue #3545)
func testSelectionChangeUpdatesTrackedCursorPosition() {
let content = NSMutableAttributedString(string: "Hello world")
let bindingContent: Binding<NSMutableAttributedString> = Binding(get: { content }, set: { _ in })

var trackedCursorPosition: Int? = nil
let coordinator = TextViewWrapper.Coordinator(
attributedText: bindingContent,
getFocusWordForMention: nil,
updateCursorPosition: { trackedCursorPosition = $0 },
initialTextSuffix: nil,
convertMentionRef: nil
)

let textView = UITextView()
textView.attributedText = content

// Simulate the user tapping to place the caret mid-text
textView.selectedRange = NSRange(location: 5, length: 0)
coordinator.textViewDidChangeSelection(textView)
XCTAssertEqual(trackedCursorPosition, 5, "A user-driven caret move should update the tracked cursor position")

// Range selections cannot be represented by a single index, so they are not tracked
trackedCursorPosition = nil
textView.selectedRange = NSRange(location: 2, length: 3)
coordinator.textViewDidChangeSelection(textView)
XCTAssertNil(trackedCursorPosition, "Range selections should not update the tracked cursor position")

// Programmatic selection changes made by updateUIView should not be recorded
coordinator.isApplyingProgrammaticChange = true
textView.selectedRange = NSRange(location: 8, length: 0)
coordinator.textViewDidChangeSelection(textView)
XCTAssertNil(trackedCursorPosition, "Programmatic selection changes should not update the tracked cursor position")
}

/// Tests that client tags are added to events when provided.
func testToEventAddsClientTagWhenProvided() {
let post = NostrPost(content: "gm")
Expand Down