damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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:
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/Views/PostView.swift | 2++
Mdamus/Views/TextViewWrapper.swift | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
AdamusTests/PostViewTests.swift | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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)") +} + + +