From 65ed060639fe35ef2df434a3efd06ca248287d97 Mon Sep 17 00:00:00 2001 From: Nora Trapp Date: Sun, 26 Jul 2020 13:20:42 -0700 Subject: [PATCH] Refine MentionTextView, add cut/copy/paste support --- Signal.xcodeproj/project.pbxproj | 12 + .../ConversationInputToolbar.h | 3 + .../ConversationInputToolbar.m | 8 + .../ConversationViewController+Mentions.swift | 35 ++ .../ConversationViewController.m | 1 + .../Views/Mentions/MentionRange.swift | 45 ++ .../Views/Mentions/MentionText.swift | 49 ++ .../Mentions/MentionTextAttachment.swift | 67 ++- .../Views/Mentions/MentionTextView.swift | 437 ++++++++++++++---- .../attachments/SignalAttachment.swift | 6 + 10 files changed, 567 insertions(+), 96 deletions(-) create mode 100644 Signal/src/ViewControllers/ConversationView/ConversationViewController+Mentions.swift create mode 100644 SignalMessaging/Views/Mentions/MentionRange.swift create mode 100644 SignalMessaging/Views/Mentions/MentionText.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 457a6896bd..bce03f26cd 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -641,6 +641,9 @@ 8851DB3F24CCE9C8001EACD2 /* MentionTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8851DB3E24CCE9C8001EACD2 /* MentionTextView.swift */; }; 8851DB4124CCE9E5001EACD2 /* MentionTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8851DB4024CCE9E5001EACD2 /* MentionTextAttachment.swift */; }; 8851DB4324CCF0EB001EACD2 /* ConversationInputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8851DB4224CCF0EB001EACD2 /* ConversationInputTextView.swift */; }; + 8851DB4524CCFB93001EACD2 /* ConversationViewController+Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8851DB4424CCFB93001EACD2 /* ConversationViewController+Mentions.swift */; }; + 8851DB4724CDEEE6001EACD2 /* MentionRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8851DB4624CDEEE6001EACD2 /* MentionRange.swift */; }; + 8851DB4924CDEF03001EACD2 /* MentionText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8851DB4824CDEF03001EACD2 /* MentionText.swift */; }; 88535064240829950011D318 /* DateHeaderInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88535063240829950011D318 /* DateHeaderInteraction.swift */; }; 8853506824084E470011D318 /* OWSDateHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8853506724084E470011D318 /* OWSDateHeaderCell.swift */; }; 8853506A240852F90011D318 /* OWSMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88535069240852F90011D318 /* OWSMessageCell.swift */; }; @@ -1592,6 +1595,9 @@ 8851DB3E24CCE9C8001EACD2 /* MentionTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionTextView.swift; sourceTree = ""; }; 8851DB4024CCE9E5001EACD2 /* MentionTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionTextAttachment.swift; sourceTree = ""; }; 8851DB4224CCF0EB001EACD2 /* ConversationInputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationInputTextView.swift; sourceTree = ""; }; + 8851DB4424CCFB93001EACD2 /* ConversationViewController+Mentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+Mentions.swift"; sourceTree = ""; }; + 8851DB4624CDEEE6001EACD2 /* MentionRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionRange.swift; sourceTree = ""; }; + 8851DB4824CDEF03001EACD2 /* MentionText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionText.swift; sourceTree = ""; }; 88535063240829950011D318 /* DateHeaderInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateHeaderInteraction.swift; sourceTree = ""; }; 8853506724084E470011D318 /* OWSDateHeaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSDateHeaderCell.swift; sourceTree = ""; }; 88535069240852F90011D318 /* OWSMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSMessageCell.swift; sourceTree = ""; }; @@ -2199,6 +2205,7 @@ 34D1F06D1F8678AA0066283D /* ConversationViewController.h */, 34D1F06E1F8678AA0066283D /* ConversationViewController.m */, 88411B5F249B0757005D10AA /* ConversationViewController+LastVisibleSortId.swift */, + 8851DB4424CCFB93001EACD2 /* ConversationViewController+Mentions.swift */, 3457811A23EB56B300CE01C3 /* ConversationViewController+MessageRequest.swift */, 4CD675C422E7CF22008010D2 /* ConversationViewController+OWS.swift */, 88FE237D249C22080041670F /* ConversationViewController+ScrollTo.swift */, @@ -3124,6 +3131,8 @@ isa = PBXGroup; children = ( 8851DB3E24CCE9C8001EACD2 /* MentionTextView.swift */, + 8851DB4624CDEEE6001EACD2 /* MentionRange.swift */, + 8851DB4824CDEF03001EACD2 /* MentionText.swift */, 8851DB4024CCE9E5001EACD2 /* MentionTextAttachment.swift */, ); path = Mentions; @@ -4449,6 +4458,8 @@ 34AC09EF211B39B100997B47 /* ViewControllerUtils.m in Sources */, 4C046AA7236148880035B234 /* OWSGroupSyncProcessingJobQueue.swift in Sources */, 346941A2215D2EE400B5BFAD /* OWSConversationColor.m in Sources */, + 8851DB4924CDEF03001EACD2 /* MentionText.swift in Sources */, + 8851DB4724CDEEE6001EACD2 /* MentionRange.swift in Sources */, 34AC0A17211B39EA00997B47 /* VideoPlayerView.swift in Sources */, 4C9D347F23689E06006A4307 /* IncomingContactSyncJobQueue.swift in Sources */, 4C35B08A23F8A9D2003EB937 /* MessageRequestReadReceipts.swift in Sources */, @@ -4890,6 +4901,7 @@ 4C46361122EB98EC00185951 /* CameraFirstNavigationController.swift in Sources */, 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */, 34C2EEB92272244600BCA1D0 /* OWSMessageStickerView.m in Sources */, + 8851DB4524CCFB93001EACD2 /* ConversationViewController+Mentions.swift in Sources */, 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */, 8809CE8722F8FE6D00D38867 /* AttachmentKeyboard.swift in Sources */, 8810223722DF9C2300A7C44F /* OnboardingPinAttemptsExhaustedViewController.swift in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h index aed10f2319..29a056821f 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h @@ -14,6 +14,8 @@ NS_ASSUME_NONNULL_BEGIN @class SignalAttachment; @class StickerInfo; +@protocol MentionTextViewDelegate; + @protocol ConversationInputToolbarDelegate - (void)sendButtonPressed; @@ -79,6 +81,7 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)isInputViewFirstResponder; - (void)setInputTextViewDelegate:(id)value; +- (void)setMentionDelegate:(id)value; - (NSString *)messageText; - (void)setMessageText:(NSString *_Nullable)value animated:(BOOL)isAnimated; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index d2d268589e..1df7566c05 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -407,6 +407,14 @@ const CGFloat kMaxIPadTextViewHeight = 142; self.inputTextView.inputTextViewDelegate = value; } +- (void)setMentionDelegate:(id)value +{ + OWSAssertDebug(self.inputTextView); + OWSAssertDebug(value); + + self.inputTextView.mentionDelegate = value; +} + - (NSString *)messageText { OWSAssertDebug(self.inputTextView); diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Mentions.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Mentions.swift new file mode 100644 index 0000000000..3c8401ec8d --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Mentions.swift @@ -0,0 +1,35 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation + +extension ConversationViewController: MentionTextViewDelegate { + public func textViewDidBeginTypingMention(_ textView: MentionTextView) { + Logger.debug("begin typing mention") + } + + public func textViewDidEndTypingMention(_ textView: MentionTextView) { + Logger.debug("end typing mention") + } + + public func textView(_ textView: MentionTextView, didUpdateMentionText mentionText: String) { + Logger.debug("did update mention \(mentionText)") + } + + public func textView(_ textView: MentionTextView, didTapMention mention: MentionRange) { + Logger.debug("did tap mention \(mention.address)") + } + + public func textView(_ textView: MentionTextView, didDeleteMention mention: MentionRange) { + Logger.debug("did delete mention \(mention.address)") + } + + public func textView(_ textView: MentionTextView, shouldResolveMentionForAddress address: SignalServiceAddress) -> Bool { + return false + } + + public func textViewMentionStyle(_ textView: MentionTextView) -> MentionStyle { + return .composing + } +} diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 393678648d..c959ab381a 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -3776,6 +3776,7 @@ typedef enum : NSUInteger { [self.inputToolbar setMessageText:existingDraft animated:NO]; self.inputToolbar.inputToolbarDelegate = self; self.inputToolbar.inputTextViewDelegate = self; + self.inputToolbar.mentionDelegate = self; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _inputToolbar); [self reloadBottomBar]; } diff --git a/SignalMessaging/Views/Mentions/MentionRange.swift b/SignalMessaging/Views/Mentions/MentionRange.swift new file mode 100644 index 0000000000..5e17654a7f --- /dev/null +++ b/SignalMessaging/Views/Mentions/MentionRange.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objcMembers +public class MentionRange: NSObject, NSSecureCoding { + public static var supportsSecureCoding = true + + public let location: Int + public let length: Int + public let address: SignalServiceAddress + + public var nsRange: NSRange { .init(location: location, length: length) } + + public convenience init(nsRange: NSRange, address: SignalServiceAddress) { + self.init(location: nsRange.location, length: nsRange.length, address: address) + } + + public init(location: Int, length: Int, address: SignalServiceAddress) { + self.location = location + self.length = length + self.address = address + + super.init() + } + + public required init?(coder: NSCoder) { + guard let address = coder.decodeObject(of: SignalServiceAddress.self, forKey: "address") else { + owsFailDebug("Failed to decode MentionRange") + return nil + } + + self.location = coder.decodeInteger(forKey: "location") + self.length = coder.decodeInteger(forKey: "length") + self.address = address + } + + public func encode(with coder: NSCoder) { + coder.encode(address, forKey: "address") + coder.encode(location, forKey: "location") + coder.encode(length, forKey: "length") + } +} diff --git a/SignalMessaging/Views/Mentions/MentionText.swift b/SignalMessaging/Views/Mentions/MentionText.swift new file mode 100644 index 0000000000..15c72c7a4a --- /dev/null +++ b/SignalMessaging/Views/Mentions/MentionText.swift @@ -0,0 +1,49 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objcMembers +public class MentionText: NSObject, NSSecureCoding { + public static var supportsSecureCoding = true + + public let text: String + public let ranges: [MentionRange] + + public init(text: String, ranges: [MentionRange]) { + self.text = text + self.ranges = ranges + + super.init() + } + + public required init?(coder: NSCoder) { + let rangesCount = coder.decodeInteger(forKey: "rangesCount") + + var ranges = [MentionRange]() + for idx in 0.. CGRect { + if let cachedBounds = cachedBounds, cachedProposedWidth == proposedWidth { return cachedBounds } + + let maxWidth = proposedWidth - mentionPadding * 2 - func calculateBounds() -> CGRect { - label.text = MentionTextView.mentionPrefix + Environment.shared.contactsManager.displayName(for: address) label.sizeToFit() - label.frame = label.frame.insetBy(dx: -mentionPadding, dy: -mentionPadding) - return CGRect( + label.frame.size.width += mentionPadding * 2 + label.frame.size.height += mentionPadding * 2 + + if label.width > maxWidth { + label.frame.size.width = maxWidth + } + + let bounds = CGRect( origin: CGPoint(x: 0, y: label.font.ascender - label.font.lineHeight - mentionPadding), size: label.frame.size ) + cachedBounds = bounds + cachedProposedWidth = proposedWidth + cachedImage = nil + return bounds } - public override func image( + private var cachedImage: UIImage? + override func image( forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int ) -> UIImage? { - label.backgroundColor = #colorLiteral(red: 0.8, green: 0.8, blue: 0.8, alpha: 1) - label.textColor = Theme.primaryTextColor + if let cachedImage = cachedImage { return cachedImage } + + switch style { + case .incoming: + label.backgroundColor = Theme.isDarkThemeEnabled ? .ows_blackAlpha20 : UIColor(rgbHex: 0xCCCCCC) + label.textColor = ConversationStyle.bubbleTextColorIncoming + case .outgoing: + label.backgroundColor = Theme.isDarkThemeEnabled ? .ows_blackAlpha20 : .ows_signalBlueDark + label.textColor = ConversationStyle.bubbleTextColorOutgoing + } let renderer = UIGraphicsImageRenderer(size: imageBounds.size) - return renderer.image { ctx in + let image = renderer.image { ctx in label.layer.render(in: ctx.cgContext) } + cachedImage = image + return image } - public override func attachmentBounds( + override func attachmentBounds( for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int ) -> CGRect { - return calculateBounds() + return calculateBounds(proposedWidth: lineFrag.width) } } diff --git a/SignalMessaging/Views/Mentions/MentionTextView.swift b/SignalMessaging/Views/Mentions/MentionTextView.swift index a1076f8383..083f1cf24f 100644 --- a/SignalMessaging/Views/Mentions/MentionTextView.swift +++ b/SignalMessaging/Views/Mentions/MentionTextView.swift @@ -10,13 +10,28 @@ public protocol MentionTextViewDelegate: UITextViewDelegate { func textViewDidEndTypingMention(_ textView: MentionTextView) func textView(_ textView: MentionTextView, didUpdateMentionText mentionText: String) - func textView(_ textView: MentionTextView, didTapMention: MentionTextAttachment) - func textView(_ textView: MentionTextView, didDeleteMention: MentionTextAttachment) + func textView(_ textView: MentionTextView, didTapMention: MentionRange) + func textView(_ textView: MentionTextView, didDeleteMention: MentionRange) + + func textView(_ textView: MentionTextView, shouldResolveMentionForAddress address: SignalServiceAddress) -> Bool + func textViewMentionStyle(_ textView: MentionTextView) -> MentionStyle } @objc -open class MentionTextView: OWSTextView, UITextViewDelegate { +open class MentionTextView: OWSTextView { public static let mentionPrefix = "@" + public static let mentionPrefixLength = 1 + + @objc + public weak var mentionDelegate: MentionTextViewDelegate? + + public override var delegate: UITextViewDelegate? { + didSet { + if let delegate = delegate { + owsAssertDebug(delegate === self) + } + } + } public required init() { super.init(frame: .zero, textContainer: nil) @@ -27,101 +42,359 @@ open class MentionTextView: OWSTextView, UITextViewDelegate { fatalError("init(coder:) has not been implemented") } - public override var delegate: UITextViewDelegate? { - didSet { owsAssertDebug(delegate === self) } - } + // MARK: - - public weak var mentionDelegate: MentionTextViewDelegate? - - public func appendMention(_ mention: MentionTextAttachment) { - insertMention(mention, at: textStorage.length) - } - - public func insertMention(_ mention: MentionTextAttachment, at location: Int) { - let beforeFont = font - textStorage.insert(NSAttributedString(attachment: mention), at: location) - font = beforeFont - } - - public func replaceCharacters(in range: NSRange, with mention: MentionTextAttachment) { - let beforeFont = font - textStorage.replaceCharacters(in: range, with: NSAttributedString(attachment: mention)) - font = beforeFont - } - - private(set) var isTypingInMentionRange = false - private func startTypingMention(mentionText: String) { - guard !isTypingInMentionRange else { - mentionDelegate?.textView(self, didUpdateMentionText: mentionText) - return + public func insertTypedMention(address: SignalServiceAddress) { + guard case .typingMention(let range) = state else { + return owsFailDebug("Can't finish typing when no mention in progress") } - isTypingInMentionRange = true + guard range.location >= Self.mentionPrefixLength else { + return owsFailDebug("Invalid mention range \(range)") + } - mentionDelegate?.textViewDidBeginTypingMention(self) + replaceCharacters( + with: MentionRange( + location: range.location - Self.mentionPrefixLength, + length: range.length + Self.mentionPrefixLength, + address: address + ), + alwaysResolveMention: true + ) } - private func stopTypingMention() { - guard isTypingInMentionRange else { return } - - isTypingInMentionRange = false - - mentionDelegate?.textViewDidEndTypingMention(self) + public func replaceCharacters(with mention: MentionRange, alwaysResolveMention: Bool = false) { + replaceCharacters(with: mention, inMutableString: textStorage, alwaysResolveMention: alwaysResolveMention) } - open func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - var deletingMention: MentionTextAttachment? + public func replaceCharacters( + with mention: MentionRange, + inMutableString mutableString: NSMutableAttributedString, + alwaysResolveMention: Bool = false + ) { + guard let mentionDelegate = mentionDelegate else { + return owsFailDebug("Can't replace characters without delegate") + } - textView.textStorage.enumerateAttribute( + let replacementString: NSAttributedString + if alwaysResolveMention || mentionDelegate.textView(self, shouldResolveMentionForAddress: mention.address) { + let mentionAttachemnt = MentionTextAttachment( + address: mention.address, + style: mentionDelegate.textViewMentionStyle(self) + ) + + replacementString = NSAttributedString(attachment: mentionAttachemnt) + } else { + // If we shouldn't resolve the mention, insert the plaintext representation. + let displayName = Self.mentionPrefix + Environment.shared.contactsManager.displayName(for: mention.address) + replacementString = NSAttributedString(string: displayName, attributes: typingAttributes) + } + + if mutableString === textStorage { + replaceCharacters(in: mention.nsRange, with: replacementString) + } else { + mutableString.replaceCharacters(in: mention.nsRange, with: replacementString) + } + } + + public func replaceCharacters(in range: NSRange, with mentionText: MentionText) { + let attributedMentions = NSMutableAttributedString(string: mentionText.text, attributes: typingAttributes) + + // We must enumerate the ranges in reverse, so as we replace a ranges + // text we do not change the previous ranges. + for mention in mentionText.ranges.sorted(by: { $0.location > $1.location }) { + replaceCharacters(with: mention, inMutableString: attributedMentions) + } + + replaceCharacters(in: range, with: attributedMentions) + } + + public func replaceCharacters(in range: NSRange, with string: String) { + replaceCharacters(in: range, with: NSAttributedString(string: string, attributes: typingAttributes)) + } + + public func replaceCharacters(in range: NSRange, with attributedString: NSAttributedString) { + let previouslySelectedRange = selectedRange + let previousFont = font + + textStorage.replaceCharacters(in: range, with: attributedString) + + // There is a bug where the font gets reset after inserting an NSTextAttachment. + // We restore the font afterwards to work around this bug. + font = previousFont + + updateSelectedRangeAfterReplacement( + previouslySelectedRange: previouslySelectedRange, + replacedRange: range, + replacementLength: attributedString.length + ) + + textViewDidChange(self) + } + + private func updateSelectedRangeAfterReplacement(previouslySelectedRange: NSRange, replacedRange: NSRange, replacementLength: Int) { + let replacedRangeEnd = replacedRange.location + replacedRange.length + + let replacedRangeIntersectsSelectedRange = previouslySelectedRange.location <= replacedRange.location + && previouslySelectedRange.location < replacedRangeEnd + + let replacedRangeIsEntirelyBeforeSelectedRange = replacedRangeEnd <= previouslySelectedRange.location + + // If the replaced range intersected the selected range, move the cursor after the replacement text + if replacedRangeIntersectsSelectedRange { + selectedRange = NSRange(location: replacedRange.location + replacementLength, length: 0) + + // If the replaced range was entirely before the selected range, shift the selected range to + // account for our newly inserted text. + } else if replacedRangeIsEntirelyBeforeSelectedRange { + selectedRange = NSRange( + location: previouslySelectedRange.location + (replacementLength - replacedRange.length), + length: previouslySelectedRange.length + ) + } + } + + public var currentlyTypingMentionText: String? { + guard case .typingMention(let range) = state else { return nil } + guard textStorage.length >= range.location + range.length else { return nil } + guard range.length > 0 else { return "" } + + return attributedText.attributedSubstring(from: range).string + } + + override open var text: String! { + get { + textWithPlaintextMentions(in: NSRange(location: 0, length: textStorage.length)) + } + set { + attributedText = NSAttributedString(string: newValue ?? "", attributes: typingAttributes) + } + } + + public func textWithPlaintextMentions(in range: NSRange) -> String { + guard range.length > 0 else { return "" } + + guard range.location >= 0, range.location + range.length <= textStorage.length else { + owsFailDebug("unexpected range \(range)") + return "" + } + + var text = attributedText.attributedSubstring(from: range).string + // Replace all mention placeholders with their plaintext representation. + textStorage.enumerateAttribute( + .attachment, + in: range, + options: [.longestEffectiveRangeNotRequired, .reverse] + ) { attachment, attachmentRange, _ in + guard let attachment = attachment as? MentionTextAttachment else { return } + text = (text as NSString).replacingCharacters( + in: NSRange(location: attachmentRange.location - range.location, length: attachmentRange.length), + with: attachment.text + ) + } + return text + } + + @objc + public var mentionText: MentionText? { + get { mentionText(in: NSRange(location: 0, length: textStorage.length)) } + set { + guard let mentionText = newValue else { + text = nil + return + } + replaceCharacters( + in: NSRange(location: 0, length: textStorage.length), + with: mentionText + ) + } + } + + @objc + public func mentionText(in range: NSRange) -> MentionText? { + var ranges = [MentionRange]() + + textStorage.enumerateAttribute( .attachment, in: range, options: .longestEffectiveRangeNotRequired - ) { attachment, attachmentRange, stop in - guard range == attachmentRange, let attachment = attachment as? MentionTextAttachment else { return } - - deletingMention = attachment - stop.pointee = true + ) { attachment, attachmentRange, _ in + guard let attachment = attachment as? MentionTextAttachment else { return } + ranges.append(.init( + location: attachmentRange.location - range.location, + length: attachmentRange.length, + address: attachment.address + )) } - if let deletingMention = deletingMention { - textView.textStorage.replaceCharacters( - in: range, - with: NSAttributedString(string: Self.mentionPrefix, attributes: textView.typingAttributes) + guard !ranges.isEmpty else { return nil } + + return MentionText(text: attributedText.attributedSubstring(from: range).string, ranges: ranges) + } + + // MARK: - Mention State + + private enum State: Equatable { + case typingMention(range: NSRange) + case notTypingMention + } + private var state: State = .notTypingMention { + didSet { + switch state { + case .notTypingMention: + if oldValue != .notTypingMention { + mentionDelegate?.textViewDidEndTypingMention(self) + } + case .typingMention: + if oldValue == .notTypingMention { + mentionDelegate?.textViewDidBeginTypingMention(self) + } + + guard let currentlyTypingMentionText = currentlyTypingMentionText else { + return owsFailDebug("unexpectedly missing mention text while typing a mention") + } + + mentionDelegate?.textView(self, didUpdateMentionText: currentlyTypingMentionText) + } + } + } + + private func shouldUpdateMentionText(in range: NSRange, changedText text: String) -> Bool { + var deletedMentions = [MentionRange]() + var deletedExactlyOneMention = false + + textStorage.enumerateAttribute( + .attachment, + in: range, + options: .longestEffectiveRangeNotRequired + ) { attachment, attachmentRange, _ in + guard let attachment = attachment as? MentionTextAttachment else { return } + + deletedMentions.append( + MentionRange(nsRange: attachmentRange, address: attachment.address) ) - textView.selectedRange = NSRange(location: range.location + 1, length: 0) - mentionDelegate?.textView(self, didDeleteMention: deletingMention) - startTypingMention(mentionText: "") - return false - } else { - return mentionDelegate?.textView?(textView, shouldChangeTextIn: range, replacementText: text) ?? true + if attachmentRange == range { deletedExactlyOneMention = true } } + + for deletedMention in deletedMentions { + mentionDelegate?.textView(self, didDeleteMention: deletedMention) + } + + // If the deleted range matched exactly one mention, we'll handle the + // delete internally. We remove the mention and replace it with an @ + // so the user can start typing a new mention to replace the deleted + // mention immediately. + if deletedExactlyOneMention { + replaceCharacters(in: range, with: Self.mentionPrefix) + selectedRange = NSRange(location: range.location + Self.mentionPrefixLength, length: 0) + return false + } + + return true + } + + private func updateMentionStateAfterCursorMove() { + guard selectedRange.length == 0, selectedRange.location > 0, textStorage.length > 0 else { + state = .notTypingMention + return + } + + var location = selectedRange.location + + while location > 0 { + let possiblePrefix = attributedText.attributedSubstring( + from: NSRange(location: location - Self.mentionPrefixLength, length: Self.mentionPrefixLength) + ).string + + // If we find whitespace before the selected range, we're not typing a mention. + // Mention typing breaks on whitespace. + if possiblePrefix.unicodeScalars.allSatisfy({ NSCharacterSet.whitespacesAndNewlines.contains($0) }) { + state = .notTypingMention + return + + // If we find the mention prefix before the selected range, we may be typing a mention. + } else if possiblePrefix == Self.mentionPrefix { + + // If there's more text before the mention prefix, check if it's whitespace. Mentions + // only start at the beginning of the string OR after a whitespace character. + if location - Self.mentionPrefixLength > 0 { + let characterPrecedingPrefix = attributedText.attributedSubstring( + from: NSRange(location: location - Self.mentionPrefixLength - 1, length: Self.mentionPrefixLength) + ).string + + // If it's not whitespace, keep looking back. Mention text can contain an "@" character, + // for example when trying to match a profile name that contains "@" + if !characterPrecedingPrefix.unicodeScalars.allSatisfy({ NSCharacterSet.whitespacesAndNewlines.contains($0) }) { + location -= 1 + continue + } + } + + state = .typingMention( + range: NSRange(location: location, length: selectedRange.location - location) + ) + return + } else { + location -= 1 + } + } + + // We checked everything, so we're not typing + state = .notTypingMention + } +} + +// MARK: - Cut/Copy/Paste + +extension MentionTextView { + open override func cut(_ sender: Any?) { + copy(sender) + replaceCharacters(in: selectedRange, with: "") + } + + public static let pasteboardType = "private.archived-mention-text" + open override func copy(_ sender: Any?) { + guard let plaintextData = textWithPlaintextMentions(in: selectedRange).data(using: .utf8) else { + return owsFailDebug("Failed to calculate plaintextData on copy") + } + + if let mentionText = mentionText(in: selectedRange) { + let encodedMentionText = NSKeyedArchiver.archivedData(withRootObject: mentionText) + UIPasteboard.general.setItems([[Self.pasteboardType: encodedMentionText]], options: [.localOnly: true]) + } else { + UIPasteboard.general.setItems([], options: [:]) + } + + UIPasteboard.general.addItems([["public.utf8-plain-text": plaintextData]]) + } + + open override func paste(_ sender: Any?) { + if let encodedMentionText = UIPasteboard.general.data(forPasteboardType: Self.pasteboardType), + let mentionText = try? NSKeyedUnarchiver.unarchivedObject(ofClass: MentionText.self, from: encodedMentionText) { + replaceCharacters(in: selectedRange, with: mentionText) + } else if let string = UIPasteboard.general.strings?.first { + replaceCharacters(in: selectedRange, with: string) + } + } +} + +// MARK: - UITextViewDelegate + +extension MentionTextView: UITextViewDelegate { + open func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + guard shouldUpdateMentionText(in: range, changedText: text) else { return false } + return mentionDelegate?.textView?(textView, shouldChangeTextIn: range, replacementText: text) ?? true } open func textViewDidChangeSelection(_ textView: UITextView) { mentionDelegate?.textViewDidChangeSelection?(textView) + updateMentionStateAfterCursorMove() + } - guard textView.selectedRange.length == 0, - textView.selectedRange.location > 0 else { return stopTypingMention() } - - var location = textView.selectedRange.location - - var possibleMentionText = "" - - while location > 0 { - let previousCharacter = textView.attributedText.attributedSubstring(from: NSRange(location: location - 1, length: 1)).string - - if previousCharacter.unicodeScalars.allSatisfy({ NSCharacterSet.whitespacesAndNewlines.contains($0) }) { - stopTypingMention() - break - } else if previousCharacter == Self.mentionPrefix { - startTypingMention(mentionText: possibleMentionText) - break - } else { - location -= 1 - possibleMentionText = previousCharacter + possibleMentionText - } - } + open func textViewDidChange(_ textView: UITextView) { + mentionDelegate?.textViewDidChange?(textView) + if textStorage.length == 0 { updateMentionStateAfterCursorMove() } } open func textView( @@ -131,7 +404,13 @@ open class MentionTextView: OWSTextView, UITextViewDelegate { interaction: UITextItemInteraction ) -> Bool { if let mention = textAttachment as? MentionTextAttachment { - mentionDelegate?.textView(self, didTapMention: mention) + mentionDelegate?.textView( + self, + didTapMention: MentionRange( + nsRange: characterRange, + address: mention.address + ) + ) } return mentionDelegate?.textView?( @@ -158,10 +437,6 @@ open class MentionTextView: OWSTextView, UITextViewDelegate { mentionDelegate?.textViewDidEndEditing?(textView) } - open func textViewDidChange(_ textView: UITextView) { - mentionDelegate?.textViewDidChange?(textView) - } - open func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { return mentionDelegate?.textView?(textView, shouldInteractWith: URL, in: characterRange, interaction: interaction) ?? true } diff --git a/SignalMessaging/attachments/SignalAttachment.swift b/SignalMessaging/attachments/SignalAttachment.swift index 607f80127d..a978e7f03c 100644 --- a/SignalMessaging/attachments/SignalAttachment.swift +++ b/SignalMessaging/attachments/SignalAttachment.swift @@ -543,6 +543,12 @@ public class SignalAttachment: NSObject { return false } + // The mention text view has a special pasteboard type, if we see it + // we know that the pasteboard contains text. + guard !pasteboardUTISet.contains(MentionTextView.pasteboardType) else { + return true + } + // The pasteboard can be populated with multiple UTI types // with different payloads. iMessage for example will copy // an animated GIF to the pasteboard with the following UTI