Refine MentionTextView, add cut/copy/paste support
This commit is contained in:
parent
ff17567a6b
commit
65ed060639
@ -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 */,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
|
||||
45
SignalMessaging/Views/Mentions/MentionRange.swift
Normal file
45
SignalMessaging/Views/Mentions/MentionRange.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
49
SignalMessaging/Views/Mentions/MentionText.swift
Normal file
49
SignalMessaging/Views/Mentions/MentionText.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user