Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
28 changes: 28 additions & 0 deletions damus/Shared/Components/Text/TextViewWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ struct TextViewWrapper: UIViewRepresentable {
uiView.linkTextAttributes = linkAttributes
}

/// Pushes the SwiftUI-side attributed text into the text view and restores the
/// caret position, which UIKit resets whenever `attributedText` is re-assigned.
///
/// Every text/selection change made here is programmatic, so the coordinator's
/// selection tracking is suppressed for the duration of this update: the delegate
/// callbacks fired by these changes must not be recorded as user-driven caret moves.
func updateUIView(_ uiView: UITextView, context: Context) {
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 +117,11 @@ struct TextViewWrapper: UIViewRepresentable {
let initialTextSuffix: String?
var initialTextSuffixWasAdded: Bool = false
var convertMentionRef: ((Pubkey) -> NSMutableAttributedString?)? = nil
/// True while `updateUIView` applies programmatic text/selection changes.
///
/// `textViewDidChangeSelection` checks this flag so that delegate callbacks
/// fired by those changes are not 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 +163,20 @@ struct TextViewWrapper: UIViewRepresentable {
updateCursorPosition(textView.selectedRange.location)
}

/// Keeps the tracked cursor position in sync when the user moves the caret
/// without editing the text (e.g. by tapping mid-text).
///
/// Without this, the position recorded at the last text change goes stale,
/// and the next view update (e.g. one triggered by an iOS keyboard switch)
/// would re-apply it, jumping the caret to the end of the text (issue #3545).
///
/// Programmatic selection changes are ignored while `isApplyingProgrammaticChange`
/// is set, as are range selections, which a single index cannot represent.
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