commit dcb94635ea0b202e6828072a490f0ee859164e80
parent c464a26151046e17cd469f390a3212b8af1c9c8e
Author: Daniel D’Aquino <daniel@daquino.me>
Date: Sat, 19 Aug 2023 19:04:18 +0000
Fix text editing issues on characters added right after mention link
Changelog-Fixed: Fix text editing issues on characters added right after mention link
Closes: https://github.com/damus-io/damus/issues/1375
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Tested-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
Diffstat:
4 files changed, 214 insertions(+), 3 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
@@ -383,6 +383,7 @@
BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; };
BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; };
D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; };
+ D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71DC1EB2A9129C3006E207C /* PostViewTests.swift */; };
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; };
D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */; };
E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; };
@@ -936,6 +937,7 @@
BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; };
BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; };
D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
+ D71DC1EB2A9129C3006E207C /* PostViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewTests.swift; sourceTree = "<group>"; };
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = "<group>"; };
E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = "<group>"; };
@@ -1751,6 +1753,7 @@
4C684A542A7E91FE005E6031 /* LongPostTests.swift */,
4C684A562A7FFAE6005E6031 /* UrlTests.swift */,
D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */,
+ D71DC1EB2A9129C3006E207C /* PostViewTests.swift */,
);
path = damusTests;
sourceTree = "<group>";
@@ -2457,6 +2460,7 @@
4C9B0DEE2A65A75F00CBDA21 /* AttrStringTestExtensions.swift in Sources */,
4C19AE552A5D977400C90DB7 /* HashtagTests.swift in Sources */,
3A3040ED29A5CB86008A0F29 /* ReplyDescriptionTests.swift in Sources */,
+ D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */,
3AAC7A022A60FE72002B50DF /* LocalizationUtilTests.swift in Sources */,
D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */,
4C8D00D429E3C5D40036AF10 /* NIP19Tests.swift in Sources */,
diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift
@@ -204,6 +204,8 @@ struct PostView: View {
TextViewWrapper(attributedText: $post, postTextViewCanScroll: $postTextViewCanScroll, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in
focusWordAttributes = (word, range)
self.newCursorIndex = nil
+ }, updateCursorPosition: { newCursorIndex in
+ self.newCursorIndex = newCursorIndex
})
.environmentObject(tagModel)
.focused($focus)
diff --git a/damus/Views/TextViewWrapper.swift b/damus/Views/TextViewWrapper.swift
@@ -14,6 +14,7 @@ struct TextViewWrapper: UIViewRepresentable {
let cursorIndex: Int?
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
+ let updateCursorPosition: ((Int) -> Void)
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
@@ -34,11 +35,11 @@ struct TextViewWrapper: UIViewRepresentable {
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.isScrollEnabled = postTextViewCanScroll
- let range = uiView.selectedRange
uiView.attributedText = attributedText
TextViewWrapper.setTextProperties(uiView)
setCursorPosition(textView: uiView)
+ let range = uiView.selectedRange
uiView.selectedRange = NSRange(location: range.location + tagModel.diff, length: range.length)
tagModel.diff = 0
@@ -52,16 +53,18 @@ struct TextViewWrapper: UIViewRepresentable {
}
func makeCoordinator() -> Coordinator {
- Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention)
+ Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition)
}
class Coordinator: NSObject, UITextViewDelegate {
@Binding var attributedText: NSMutableAttributedString
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
+ let updateCursorPosition: ((Int) -> Void)
- init(attributedText: Binding<NSMutableAttributedString>, getFocusWordForMention: ((String?, NSRange?) -> Void)?) {
+ init(attributedText: Binding<NSMutableAttributedString>, getFocusWordForMention: ((String?, NSRange?) -> Void)?, updateCursorPosition: @escaping ((Int) -> Void)) {
_attributedText = attributedText
self.getFocusWordForMention = getFocusWordForMention
+ self.updateCursorPosition = updateCursorPosition
}
func textViewDidChange(_ textView: UITextView) {
@@ -96,6 +99,65 @@ struct TextViewWrapper: UIViewRepresentable {
}
return NSRange(location: startOffset, length: length)
}
+
+ // This `UITextViewDelegate` method is automatically called by the editor when edits occur, to check whether a change should occur
+ // We will use this method to manually handle edits concerning mention ("@") links, to avoid manual text edits to attributed mention links
+ func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
+ guard let attributedString = textView.attributedText else {
+ return true // If we cannot get an attributed string, just fail gracefully and allow changes
+ }
+ var mutable = NSMutableAttributedString(attributedString: attributedString)
+
+ let entireRange = NSRange(location: 0, length: attributedString.length)
+ var shouldAllowChange = true
+ var performEditActionManually = false
+
+ attributedString.enumerateAttribute(.link, in: entireRange, options: []) { (value, linkRange, stop) in
+ guard value != nil else {
+ return // This range is not a link. Skip checking.
+ }
+
+ if range.contains(linkRange.upperBound) && range.contains(linkRange.lowerBound) {
+ // Edit range engulfs all of this link's range.
+ // This link will naturally disappear, so no work needs to be done in this range.
+ 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)
+ // Perform action manually to flush above changes to the view, and to prevent the character being added from having an attributed link property
+ performEditActionManually = true
+ return
+ }
+ else if range.location == linkRange.location + linkRange.length && range.length == 0 {
+ // If we are inserting a character at the right edge of a link, UITextInput tends to include the new character inside the link.
+ // Therefore, we need to manually append that character outside of the link
+ performEditActionManually = true
+ return
+ }
+ }
+
+ if performEditActionManually {
+ shouldAllowChange = false
+ addUnattributedText(text, to: &mutable, inRange: range)
+ attributedText = mutable
+
+ // Move caret to the end of the newly changed text.
+ updateCursorPosition(range.location + text.count)
+ }
+
+ return shouldAllowChange
+ }
+
+ func addUnattributedText(_ text: String, to attributedString: inout NSMutableAttributedString, inRange range: NSRange) {
+ if range.length == 0 {
+ attributedString.insert(NSAttributedString(string: text, attributes: nil), at: range.location)
+ }
+ else {
+ attributedString.replaceCharacters(in: range, with: text)
+ }
+ }
+
}
}
diff --git a/damusTests/PostViewTests.swift b/damusTests/PostViewTests.swift
@@ -0,0 +1,143 @@
+//
+// PostViewTests.swift
+// damusTests
+//
+// Created by Daniel D’Aquino on 2023-08-19.
+//
+import Foundation
+import XCTest
+@testable import damus
+import SwiftUI
+
+final class PostViewTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ /// Based on https://github.com/damus-io/damus/issues/1375
+ /// Tests whether the editor properly handles mention links after they have been added, to avoid manual editing of attributed links
+ func testMentionLinkEditorHandling() throws {
+ var content: NSMutableAttributedString
+
+ // Test normal insertion
+ checkMentionLinkEditorHandling(content: NSMutableAttributedString(string: "Hello"), replacementText: "@", replacementRange: NSRange(location: 0, length: 0), shouldBeAbleToChangeAutomatically: true)
+ checkMentionLinkEditorHandling(content: NSMutableAttributedString(string: "Hello "), replacementText: "@", replacementRange: NSRange(location: 6, length: 0), shouldBeAbleToChangeAutomatically: true)
+ checkMentionLinkEditorHandling(content: NSMutableAttributedString(string: "Helo "), replacementText: "l", replacementRange: NSRange(location: 3, length: 0), shouldBeAbleToChangeAutomatically: true)
+
+ // Test normal backspacing
+ checkMentionLinkEditorHandling(content: NSMutableAttributedString(string: "Hello"), replacementText: "", replacementRange: NSRange(location: 5, length: 1), shouldBeAbleToChangeAutomatically: true)
+ checkMentionLinkEditorHandling(content: NSMutableAttributedString(string: "Hello "), replacementText: "", replacementRange: NSRange(location: 6, length: 1), shouldBeAbleToChangeAutomatically: true)
+ checkMentionLinkEditorHandling(content: NSMutableAttributedString(string: "Helo "), replacementText: "", replacementRange: NSRange(location: 3, length: 1), shouldBeAbleToChangeAutomatically: true)
+
+ // Test normal insertion after mention link
+ 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: 12, length: 0), shouldBeAbleToChangeAutomatically: true)
+
+ // Test insertion right at the end of a mention link, at the end of the text
+ content = NSMutableAttributedString(string: "Hello @user")
+ content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 5))
+ checkMentionLinkEditorHandling(content: content, replacementText: ",", replacementRange: NSRange(location: 11, length: 0), shouldBeAbleToChangeAutomatically: false, expectedNewCursorIndex: 12, handleNewContent: { newManuallyEditedContent in
+ XCTAssertEqual(newManuallyEditedContent.string, "Hello @user,")
+ XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 11, effectiveRange: nil))
+ })
+
+ // Test insertion right at the end of a mention link, in the middle of the text
+ content = NSMutableAttributedString(string: "Hello @user how are you?")
+ content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 5))
+ checkMentionLinkEditorHandling(content: content, replacementText: ",", replacementRange: NSRange(location: 11, length: 0), shouldBeAbleToChangeAutomatically: false, expectedNewCursorIndex: 12, handleNewContent: { newManuallyEditedContent in
+ XCTAssertEqual(newManuallyEditedContent.string, "Hello @user, how are you?")
+ XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 11, effectiveRange: nil))
+ })
+
+ // Test insertion in the middle of a mention link to check if the link is removed
+ content = NSMutableAttributedString(string: "Hello @user how are you?")
+ content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 5))
+ checkMentionLinkEditorHandling(content: content, replacementText: "a", replacementRange: NSRange(location: 8, length: 0), shouldBeAbleToChangeAutomatically: false, expectedNewCursorIndex: 9, handleNewContent: { newManuallyEditedContent in
+ XCTAssertEqual(newManuallyEditedContent.string, "Hello @uaser how are you?")
+ XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 8, effectiveRange: nil))
+ })
+
+ // Test insertion in the middle of a mention link to check if the link is removed, at the end of the text
+ 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: 8, length: 0), shouldBeAbleToChangeAutomatically: false, expectedNewCursorIndex: 9, handleNewContent: { newManuallyEditedContent in
+ XCTAssertEqual(newManuallyEditedContent.string, "Hello @uaser")
+ XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 8, effectiveRange: nil))
+ })
+
+ // Test backspacing right at the end of a mention link, at the end of the text
+ content = NSMutableAttributedString(string: "Hello @user")
+ content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 5))
+ checkMentionLinkEditorHandling(content: content, replacementText: "", replacementRange: NSRange(location: 10, length: 1), shouldBeAbleToChangeAutomatically: false, expectedNewCursorIndex: 10, handleNewContent: { newManuallyEditedContent in
+ XCTAssertEqual(newManuallyEditedContent.string, "Hello @use")
+ 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
+ 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))
+ })
+
+ // Test that removing one link does not affect the other
+ content = NSMutableAttributedString(string: "Hello @user1 @user2")
+ content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 6))
+ content.addAttribute(.link, value: "damus:5678", range: NSRange(location: 13, length: 6))
+ checkMentionLinkEditorHandling(content: content, replacementText: "", replacementRange: NSRange(location: 18, length: 1), shouldBeAbleToChangeAutomatically: false, expectedNewCursorIndex: 18, handleNewContent: { newManuallyEditedContent in
+ XCTAssertEqual(newManuallyEditedContent.string, "Hello @user1 @user")
+ XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 13, effectiveRange: nil))
+ XCTAssertNotNil(newManuallyEditedContent.attribute(.link, at: 6, effectiveRange: nil))
+ })
+
+ // Test that replacing a whole range intersecting with two links removes both links
+ content = NSMutableAttributedString(string: "Hello @user1 @user2")
+ content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 6))
+ content.addAttribute(.link, value: "damus:5678", range: NSRange(location: 13, length: 6))
+ checkMentionLinkEditorHandling(content: content, replacementText: "a", replacementRange: NSRange(location: 10, length: 4), shouldBeAbleToChangeAutomatically: false, expectedNewCursorIndex: 11, handleNewContent: { newManuallyEditedContent in
+ XCTAssertEqual(newManuallyEditedContent.string, "Hello @useauser2")
+ XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 6, effectiveRange: nil))
+ XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 11, effectiveRange: nil))
+ })
+
+ // Test that replacing a whole range including two links removes both links naturally
+ content = NSMutableAttributedString(string: "Hello @user1 @user2, how are you?")
+ content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 6))
+ content.addAttribute(.link, value: "damus:5678", range: NSRange(location: 13, length: 6))
+ checkMentionLinkEditorHandling(content: content, replacementText: "", replacementRange: NSRange(location: 5, length: 28), shouldBeAbleToChangeAutomatically: true)
+
+ }
+}
+
+func checkMentionLinkEditorHandling(
+ content: NSMutableAttributedString,
+ replacementText: String,
+ replacementRange: NSRange,
+ shouldBeAbleToChangeAutomatically: Bool,
+ expectedNewCursorIndex: Int? = nil,
+ handleNewContent: ((NSMutableAttributedString) -> Void)? = nil) {
+ let bindingContent: Binding<NSMutableAttributedString> = Binding(get: {
+ return content
+ }, set: { newValue in
+ handleNewContent?(newValue)
+ })
+ let coordinator: TextViewWrapper.Coordinator = TextViewWrapper.Coordinator(attributedText: bindingContent, getFocusWordForMention: nil, updateCursorPosition: { newCursorIndex in
+ if let expectedNewCursorIndex {
+ XCTAssertEqual(newCursorIndex, expectedNewCursorIndex)
+ }
+ })
+ let textView = UITextView()
+ textView.attributedText = content
+
+ XCTAssertEqual(coordinator.textView(textView, shouldChangeTextIn: replacementRange, replacementText: replacementText), shouldBeAbleToChangeAutomatically, "Expected shouldChangeTextIn to return \(shouldBeAbleToChangeAutomatically), but was \(!shouldBeAbleToChangeAutomatically)")
+}
+
+
+