Refine MentionTextView, add cut/copy/paste support

This commit is contained in:
Nora Trapp 2020-07-26 13:20:42 -07:00
parent ff17567a6b
commit 65ed060639
10 changed files with 567 additions and 96 deletions

View File

@ -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 = "<group>"; };
8851DB4024CCE9E5001EACD2 /* MentionTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionTextAttachment.swift; sourceTree = "<group>"; };
8851DB4224CCF0EB001EACD2 /* ConversationInputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationInputTextView.swift; sourceTree = "<group>"; };
8851DB4424CCFB93001EACD2 /* ConversationViewController+Mentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+Mentions.swift"; sourceTree = "<group>"; };
8851DB4624CDEEE6001EACD2 /* MentionRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionRange.swift; sourceTree = "<group>"; };
8851DB4824CDEF03001EACD2 /* MentionText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionText.swift; sourceTree = "<group>"; };
88535063240829950011D318 /* DateHeaderInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateHeaderInteraction.swift; sourceTree = "<group>"; };
8853506724084E470011D318 /* OWSDateHeaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSDateHeaderCell.swift; sourceTree = "<group>"; };
88535069240852F90011D318 /* OWSMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSMessageCell.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -14,6 +14,8 @@ NS_ASSUME_NONNULL_BEGIN
@class SignalAttachment;
@class StickerInfo;
@protocol MentionTextViewDelegate;
@protocol ConversationInputToolbarDelegate <NSObject>
- (void)sendButtonPressed;
@ -79,6 +81,7 @@ NS_ASSUME_NONNULL_BEGIN
- (BOOL)isInputViewFirstResponder;
- (void)setInputTextViewDelegate:(id<ConversationInputTextViewDelegate>)value;
- (void)setMentionDelegate:(id<MentionTextViewDelegate>)value;
- (NSString *)messageText;
- (void)setMessageText:(NSString *_Nullable)value animated:(BOOL)isAnimated;

View File

@ -407,6 +407,14 @@ const CGFloat kMaxIPadTextViewHeight = 142;
self.inputTextView.inputTextViewDelegate = value;
}
- (void)setMentionDelegate:(id<MentionTextViewDelegate>)value
{
OWSAssertDebug(self.inputTextView);
OWSAssertDebug(value);
self.inputTextView.mentionDelegate = value;
}
- (NSString *)messageText
{
OWSAssertDebug(self.inputTextView);

View File

@ -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
}
}

View File

@ -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];
}

View File

@ -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")
}
}

View File

@ -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..<rangesCount {
guard let range = coder.decodeObject(of: MentionRange.self, forKey: "ranges.\(idx)") else {
owsFailDebug("Failed to decode ranges of MentionText")
return nil
}
ranges.append(range)
}
guard let text = coder.decodeObject(of: NSString.self, forKey: "text") as String? else {
owsFailDebug("Failed to decode MentionText")
return nil
}
self.ranges = ranges
self.text = text
}
public func encode(with coder: NSCoder) {
coder.encode(text as NSString, forKey: "text")
coder.encode(ranges.count, forKey: "rangesCount")
for (idx, range) in ranges.enumerated() {
coder.encode(range, forKey: "ranges.\(idx)")
}
}
}

View File

@ -5,20 +5,33 @@
import Foundation
@objc
public class MentionTextAttachment: NSTextAttachment {
let mentionPadding: CGFloat = 3
public enum MentionStyle: Int {
case incoming
case outgoing
public static var composing: MentionStyle = .incoming
}
class MentionTextAttachment: NSTextAttachment {
let address: SignalServiceAddress
let text: String
let style: MentionStyle
let label = UILabel()
private let mentionPadding: CGFloat = 3
private let label = UILabel()
init(address: SignalServiceAddress) {
init(address: SignalServiceAddress, style: MentionStyle) {
self.address = address
self.style = style
// TODO: Maybe don't lookup the display name here..
self.text = MentionTextView.mentionPrefix + Environment.shared.contactsManager.displayName(for: address)
super.init(data: nil, ofType: nil)
label.layer.cornerRadius = 4
label.clipsToBounds = true
label.text = text
label.font = .ows_dynamicTypeBody
label.textAlignment = .center
@ -28,39 +41,63 @@ public class MentionTextAttachment: NSTextAttachment {
fatalError("init(coder:) has not been implemented")
}
var attributedString: NSAttributedString { .init(attachment: self) }
private var cachedBounds: CGRect?
private var cachedProposedWidth: CGFloat?
func calculateBounds(proposedWidth: CGFloat) -> 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)
}
}

View File

@ -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
}

View File

@ -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