瀏覽代碼

implement link detection, reuse MessageKit's MessageLabel

cyberta 4 年之前
父節點
當前提交
8e520ac5ad

+ 4 - 2
deltachat-ios.xcodeproj/project.pbxproj

@@ -125,7 +125,7 @@
 		30E8F2512449EA0E00CE2C90 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3060119E22DDE24000C1CE6F /* Localizable.strings */; };
 		30E8F253244DAD0E00CE2C90 /* SendingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E8F252244DAD0E00CE2C90 /* SendingController.swift */; };
 		30EF7308252F6A3300E2C54A /* PaddingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30F4BFED252E3E020006B9B3 /* PaddingTextView.swift */; };
-		30EF7309252F6A3400E2C54A /* PaddingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30F4BFED252E3E020006B9B3 /* PaddingTextView.swift */; };
+		30EF7324252FF15F00E2C54A /* NewMessageLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF7323252FF15F00E2C54A /* NewMessageLabel.swift */; };
 		30F8817624DA97DA0023780E /* BackgroundContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30F8817524DA97DA0023780E /* BackgroundContainer.swift */; };
 		30F9B9EC235F2116006E7ACF /* MessageCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30F9B9EB235F2116006E7ACF /* MessageCounter.swift */; };
 		30FDB70524D1C1000066C48D /* ChatViewControllerNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30FDB6F824D1C1000066C48D /* ChatViewControllerNew.swift */; };
@@ -422,6 +422,7 @@
 		30E8F2412448B77600CE2C90 /* ChatListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListController.swift; sourceTree = "<group>"; };
 		30E8F2432449C64100CE2C90 /* ChatListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListCell.swift; sourceTree = "<group>"; };
 		30E8F252244DAD0E00CE2C90 /* SendingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendingController.swift; sourceTree = "<group>"; };
+		30EF7323252FF15F00E2C54A /* NewMessageLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMessageLabel.swift; sourceTree = "<group>"; };
 		30F4BFED252E3E020006B9B3 /* PaddingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingTextView.swift; sourceTree = "<group>"; };
 		30F8817524DA97DA0023780E /* BackgroundContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundContainer.swift; sourceTree = "<group>"; };
 		30F9B9EB235F2116006E7ACF /* MessageCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCounter.swift; sourceTree = "<group>"; };
@@ -812,6 +813,7 @@
 				302E1BB3252B5AB4008F4264 /* NewPlayButtonView.swift */,
 				30F8817524DA97DA0023780E /* BackgroundContainer.swift */,
 				3008CB7324F9436C00E6A617 /* NewAudioPlayerView.swift */,
+				30EF7323252FF15F00E2C54A /* NewMessageLabel.swift */,
 			);
 			path = Views;
 			sourceTree = "<group>";
@@ -1424,7 +1426,6 @@
 				302589FF2452FA280086C1CD /* ShareAttachment.swift in Sources */,
 				3057029F24C6445000D84EFC /* EmptyStateLabel.swift in Sources */,
 				305702A224C6455400D84EFC /* TypeAlias.swift in Sources */,
-				30EF7309252F6A3400E2C54A /* PaddingTextView.swift in Sources */,
 				30E8F2442449C64100CE2C90 /* ChatListCell.swift in Sources */,
 				30E8F2132447285600CE2C90 /* ShareViewController.swift in Sources */,
 				30E8F253244DAD0E00CE2C90 /* SendingController.swift in Sources */,
@@ -1576,6 +1577,7 @@
 				305961F82346125100C80F33 /* AudioMessageCell.swift in Sources */,
 				305961EC2346125100C80F33 /* Avatar.swift in Sources */,
 				305961CD2346125100C80F33 /* UIEdgeInsets+Extensions.swift in Sources */,
+				30EF7324252FF15F00E2C54A /* NewMessageLabel.swift in Sources */,
 				305962032346125100C80F33 /* CellSizeCalculator.swift in Sources */,
 				305961E02346125100C80F33 /* MessageLabelDelegate.swift in Sources */,
 				30C0D49D237C4908008E2A0E /* CertificateCheckController.swift in Sources */,

+ 18 - 1
deltachat-ios/Chat/ChatViewControllerNew.swift

@@ -1066,8 +1066,25 @@ class ChatViewControllerNew: UITableViewController {
 
 // MARK: - BaseMessageCellDelegate
 extension ChatViewControllerNew: BaseMessageCellDelegate {
-    func linkTapped(link: String) {
+    func phoneNumberTapped(number: String) {
+        logger.debug("phone number tapped \(number)")
     }
+
+    func commandTapped(command: String) {
+        logger.debug("command tapped \(command)")
+    }
+
+    func urlTapped(url: URL) {
+        if Utils.isEmail(url: url) {
+            logger.debug("tapped on contact")
+            let email = Utils.getEmailFrom(url)
+            self.askToChatWith(email: email)
+            ///TODO: implement handling
+        } else {
+            UIApplication.shared.open(url)
+        }
+    }
+
     func imageTapped(indexPath: IndexPath) {
         showMediaGalleryFor(indexPath: indexPath)
     }

+ 63 - 2
deltachat-ios/Chat/Views/Cells/BaseMessageCell.swift

@@ -72,6 +72,23 @@ public class BaseMessageCell: UITableViewCell {
 
     public weak var baseDelegate: BaseMessageCellDelegate?
 
+    lazy var messageLabel: PaddingTextView = {
+        let view = PaddingTextView()
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.setContentHuggingPriority(.defaultLow, for: .vertical)
+        view.font = UIFont.preferredFont(for: .body, weight: .regular)
+        view.delegate = self
+        view.enabledDetectors = [.url, .phoneNumber]
+        let attributes: [NSAttributedString.Key : Any] = [
+            NSAttributedString.Key.foregroundColor: DcColors.defaultTextColor,
+            NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue,
+            NSAttributedString.Key.underlineColor: DcColors.defaultTextColor ]
+        view.label.setAttributes(attributes, detector: .url)
+        view.label.setAttributes(attributes, detector: .phoneNumber)
+        view.isUserInteractionEnabled = true
+        return view
+    }()
+
     lazy var avatarView: InitialsBadge = {
         let view = InitialsBadge(size: 28)
         view.setColor(UIColor.gray)
@@ -180,8 +197,23 @@ public class BaseMessageCell: UITableViewCell {
         let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onAvatarTapped))
         gestureRecognizer.numberOfTapsRequired = 1
         avatarView.addGestureRecognizer(gestureRecognizer)
+
+        let messageLabelGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:)))
+        //messageLabelGestureRecognizer.delaysTouchesBegan = true
+        gestureRecognizer.numberOfTapsRequired = 1
+        messageLabel.addGestureRecognizer(messageLabelGestureRecognizer)
     }
 
+    @objc
+    open func handleTapGesture(_ gesture: UIGestureRecognizer) {
+        guard gesture.state == .ended else { return }
+
+        let touchLocation = gesture.location(in: messageLabel)
+        let _ = messageLabel.label.handleGesture(touchLocation)
+    }
+
+
+
     @objc func onAvatarTapped() {
         if let tableView = self.superview as? UITableView, let indexPath = tableView.indexPath(for: self) {
             baseDelegate?.avatarTapped(indexPath: indexPath)
@@ -231,6 +263,7 @@ public class BaseMessageCell: UITableViewCell {
         if !msg.isInfo {
             bottomLabel.attributedText = getFormattedBottomLine(message: msg)
         }
+        messageLabel.delegate = self
     }
 
     func getFormattedBottomLine(message: DcMsg) -> NSAttributedString {
@@ -315,6 +348,9 @@ public class BaseMessageCell: UITableViewCell {
         bottomLabel.text = nil
         bottomLabel.attributedText = nil
         baseDelegate = nil
+        messageLabel.text = nil
+        messageLabel.attributedText = nil
+        messageLabel.delegate = nil
     }
 
     // MARK: - Context menu
@@ -341,11 +377,36 @@ public class BaseMessageCell: UITableViewCell {
     }
 }
 
+extension BaseMessageCell: MessageLabelDelegate {
+    public func didSelectAddress(_ addressComponents: [String: String]) {}
+
+    public func didSelectDate(_ date: Date) {}
+
+    public func didSelectPhoneNumber(_ phoneNumber: String) {
+        baseDelegate?.phoneNumberTapped(number: phoneNumber)
+    }
+
+    public func didSelectURL(_ url: URL) {
+        logger.debug("did select URL")
+        baseDelegate?.urlTapped(url: url)
+    }
+
+    public func didSelectTransitInformation(_ transitInformation: [String: String]) {}
+
+    public func didSelectMention(_ mention: String) {}
+
+    public func didSelectHashtag(_ hashtag: String) {}
+
+    public func didSelectCustom(_ pattern: String, match: String?) {}
+}
+
 // MARK: - BaseMessageCellDelegate
 // this delegate contains possible events from base cells or from derived cells
 public protocol BaseMessageCellDelegate: class {
-
-    func linkTapped(link: String) // link is eg. `https://foo.bar` or `/command`
+    func commandTapped(command: String) // `/command`
+    func phoneNumberTapped(number: String)
+    func urlTapped(url: URL) // url is eg. `https://foo.bar`
     func imageTapped(indexPath: IndexPath)
     func avatarTapped(indexPath: IndexPath)
+
 }

+ 2 - 9
deltachat-ios/Chat/Views/Cells/NewAudioMessageCell.swift

@@ -17,13 +17,6 @@ public class NewAudioMessageCell: BaseMessageCell {
         return view
     }()
 
-    lazy var messageLabel: PaddingTextView = {
-        let label = PaddingTextView(top: 0, left: 12, bottom: 0, right: 12)
-        label.translatesAutoresizingMaskIntoConstraints = false
-        label.font = UIFont.preferredFont(for: .body, weight: .regular)
-        return label
-    }()
-
     private var messageId: Int = 0
 
     override func setupSubviews() {
@@ -32,6 +25,8 @@ public class NewAudioMessageCell: BaseMessageCell {
         spacerView.translatesAutoresizingMaskIntoConstraints = false
         mainContentView.addArrangedSubview(audioPlayerView)
         mainContentView.addArrangedSubview(messageLabel)
+        messageLabel.paddingLeading = 12
+        messageLabel.paddingTrailing = 12
         audioPlayerView.constraintWidthTo(250).isActive = true
         let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onPlayButtonTapped))
         gestureRecognizer.numberOfTapsRequired = 1
@@ -58,8 +53,6 @@ public class NewAudioMessageCell: BaseMessageCell {
     public override func prepareForReuse() {
         super.prepareForReuse()
         mainContentView.spacing = 0
-        messageLabel.text = nil
-        messageLabel.attributedText = nil
         messageId = 0
         delegate = nil
         audioPlayerView.reset()

+ 0 - 12
deltachat-ios/Chat/Views/Cells/NewFileTextCell.swift

@@ -71,16 +71,6 @@ class NewFileTextCell: BaseMessageCell {
         return subtitle
     }()
 
-    lazy var messageLabel: UILabel = {
-        let label = UILabel()
-        label.translatesAutoresizingMaskIntoConstraints = false
-        label.numberOfLines = 0
-        label.lineBreakMode = .byWordWrapping
-        label.setContentHuggingPriority(.defaultLow, for: .vertical)
-        label.font = UIFont.preferredFont(for: .body, weight: .regular)
-        return label
-    }()
-
     override func setupSubviews() {
         super.setupSubviews()
         let spacerView = UIView()
@@ -96,8 +86,6 @@ class NewFileTextCell: BaseMessageCell {
     }
 
     override func prepareForReuse() {
-        messageLabel.text = nil
-        messageLabel.attributedText = nil
         fileImageView.image = nil
     }
 

+ 2 - 11
deltachat-ios/Chat/Views/Cells/NewImageTextCell.swift

@@ -8,15 +8,6 @@ class NewImageTextCell: BaseMessageCell {
     var imageHeightConstraint: NSLayoutConstraint?
     var imageWidthConstraint: NSLayoutConstraint?
 
-    lazy var messageLabel: PaddingTextView = {
-        let label = PaddingTextView(top: 0, left: 12, bottom: 0, right: 12)
-        label.translatesAutoresizingMaskIntoConstraints = false
-        label.setContentHuggingPriority(.defaultLow, for: .vertical)
-        label.font = UIFont.preferredFont(for: .body, weight: .regular)
-        return label
-    }()
-
-
     lazy var contentImageView: SDAnimatedImageView = {
         let imageView = SDAnimatedImageView()
         imageView.translatesAutoresizingMaskIntoConstraints = false
@@ -42,6 +33,8 @@ class NewImageTextCell: BaseMessageCell {
         playButtonView.constraint(equalTo: CGSize(width: 50, height: 50))
         mainContentView.addArrangedSubview(contentImageView)
         mainContentView.addArrangedSubview(messageLabel)
+        messageLabel.paddingLeading = 12
+        messageLabel.paddingTrailing = 12
         contentImageView.constraintAlignLeadingMaxTo(mainContentView).isActive = true
         contentImageView.constraintAlignTrailingMaxTo(mainContentView).isActive = true
         topCompactView = true
@@ -147,8 +140,6 @@ class NewImageTextCell: BaseMessageCell {
 
     override func prepareForReuse() {
         contentImageView.image = nil
-        messageLabel.text = nil
-        messageLabel.attributedText = nil
         tag = -1
     }
 }

+ 2 - 9
deltachat-ios/Chat/Views/Cells/NewTextMessageCell.swift

@@ -4,16 +4,11 @@ import UIKit
 
 class NewTextMessageCell: BaseMessageCell {
 
-    lazy var messageLabel: PaddingTextView = {
-        let paddingView = PaddingTextView(top: 0, left: 12, bottom: 0, right: 12)
-        paddingView.translatesAutoresizingMaskIntoConstraints = false
-        paddingView.font = UIFont.preferredFont(for: .body, weight: .regular)
-        return paddingView
-    }()
-
     override func setupSubviews() {
         super.setupSubviews()
         mainContentView.addArrangedSubview(messageLabel)
+        messageLabel.paddingLeading = 12
+        messageLabel.paddingTrailing = 12
     }
 
     override func update(msg: DcMsg, messageStyle: UIRectCorner, isAvatarVisible: Bool, isGroup: Bool) {
@@ -23,8 +18,6 @@ class NewTextMessageCell: BaseMessageCell {
 
     override func prepareForReuse() {
         super.prepareForReuse()
-        messageLabel.text = nil
-        messageLabel.attributedText = nil
     }
     
 }

+ 545 - 0
deltachat-ios/Chat/Views/NewMessageLabel.swift

@@ -0,0 +1,545 @@
+/*
+ MIT License
+
+ Copyright (c) 2017-2019 MessageKit
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+ */
+
+import UIKit
+
+open class NewMessageLabel: UILabel {
+
+    // MARK: - Private Properties
+
+    private lazy var layoutManager: NSLayoutManager = {
+        let layoutManager = NSLayoutManager()
+        layoutManager.addTextContainer(self.textContainer)
+        return layoutManager
+    }()
+
+    private lazy var textContainer: NSTextContainer = {
+        let textContainer = NSTextContainer()
+        textContainer.lineFragmentPadding = 0
+        textContainer.maximumNumberOfLines = self.numberOfLines
+        textContainer.lineBreakMode = self.lineBreakMode
+        textContainer.size = self.bounds.size
+        return textContainer
+    }()
+
+    private lazy var textStorage: NSTextStorage = {
+        let textStorage = NSTextStorage()
+        textStorage.addLayoutManager(self.layoutManager)
+        return textStorage
+    }()
+
+    internal lazy var rangesForDetectors: [DetectorType: [(NSRange, NewMessageTextCheckingType)]] = [:]
+
+    private var isConfiguring: Bool = false
+
+    // MARK: - Public Properties
+
+    open weak var delegate: MessageLabelDelegate?
+
+    open var enabledDetectors: [DetectorType] = [] {
+        didSet {
+            setTextStorage(attributedText, shouldParse: true)
+        }
+    }
+
+    open override var attributedText: NSAttributedString? {
+        didSet {
+            setTextStorage(attributedText, shouldParse: true)
+        }
+    }
+
+    open override var text: String? {
+        didSet {
+            setTextStorage(attributedText, shouldParse: true)
+        }
+    }
+
+    open override var font: UIFont! {
+        didSet {
+            setTextStorage(attributedText, shouldParse: false)
+        }
+    }
+
+    open override var textColor: UIColor! {
+        didSet {
+            setTextStorage(attributedText, shouldParse: false)
+        }
+    }
+
+    open override var lineBreakMode: NSLineBreakMode {
+        didSet {
+            textContainer.lineBreakMode = lineBreakMode
+            if !isConfiguring { setNeedsDisplay() }
+        }
+    }
+
+    open override var numberOfLines: Int {
+        didSet {
+            textContainer.maximumNumberOfLines = numberOfLines
+            if !isConfiguring { setNeedsDisplay() }
+        }
+    }
+
+    open override var textAlignment: NSTextAlignment {
+        didSet {
+            setTextStorage(attributedText, shouldParse: false)
+        }
+    }
+
+    open var textInsets: UIEdgeInsets = .zero {
+        didSet {
+            if !isConfiguring { setNeedsDisplay() }
+        }
+    }
+
+    open override var intrinsicContentSize: CGSize {
+        var size = super.intrinsicContentSize
+        size.width += textInsets.horizontal
+        size.height += textInsets.vertical
+        return size
+    }
+
+    internal var messageLabelFont: UIFont?
+
+    private var attributesNeedUpdate = false
+
+    public static var defaultAttributes: [NSAttributedString.Key: Any] = {
+        return [
+            NSAttributedString.Key.foregroundColor: UIColor.darkText,
+            NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue,
+            NSAttributedString.Key.underlineColor: UIColor.darkText
+        ]
+    }()
+
+    open internal(set) var addressAttributes: [NSAttributedString.Key: Any] = defaultAttributes
+
+    open internal(set) var dateAttributes: [NSAttributedString.Key: Any] = defaultAttributes
+
+    open internal(set) var phoneNumberAttributes: [NSAttributedString.Key: Any] = defaultAttributes
+
+    open internal(set) var urlAttributes: [NSAttributedString.Key: Any] = defaultAttributes
+
+    open internal(set) var transitInformationAttributes: [NSAttributedString.Key: Any] = defaultAttributes
+
+    open internal(set) var hashtagAttributes: [NSAttributedString.Key: Any] = defaultAttributes
+
+    open internal(set) var mentionAttributes: [NSAttributedString.Key: Any] = defaultAttributes
+
+    open internal(set) var customAttributes: [NSRegularExpression: [NSAttributedString.Key: Any]] = [:]
+
+    public func setAttributes(_ attributes: [NSAttributedString.Key: Any], detector: DetectorType) {
+        switch detector {
+        case .phoneNumber:
+            phoneNumberAttributes = attributes
+        case .address:
+            addressAttributes = attributes
+        case .date:
+            dateAttributes = attributes
+        case .url:
+            urlAttributes = attributes
+        case .transitInformation:
+            transitInformationAttributes = attributes
+        case .mention:
+            mentionAttributes = attributes
+        case .hashtag:
+            hashtagAttributes = attributes
+        case .custom(let regex):
+            customAttributes[regex] = attributes
+        }
+        if isConfiguring {
+            attributesNeedUpdate = true
+        } else {
+            updateAttributes(for: [detector])
+        }
+    }
+
+    // MARK: - Initializers
+
+    public override init(frame: CGRect) {
+        super.init(frame: frame)
+        setupView()
+    }
+
+    public required init?(coder aDecoder: NSCoder) {
+        super.init(coder: aDecoder)
+        setupView()
+    }
+
+    // MARK: - Open Methods
+
+    open override func drawText(in rect: CGRect) {
+
+        let insetRect = rect.inset(by: textInsets)
+        textContainer.size = CGSize(width: insetRect.width, height: rect.height)
+
+        let origin = insetRect.origin
+        let range = layoutManager.glyphRange(for: textContainer)
+
+        layoutManager.drawBackground(forGlyphRange: range, at: origin)
+        layoutManager.drawGlyphs(forGlyphRange: range, at: origin)
+    }
+
+    // MARK: - Public Methods
+
+    public func configure(block: () -> Void) {
+        isConfiguring = true
+        block()
+        if attributesNeedUpdate {
+            updateAttributes(for: enabledDetectors)
+        }
+        attributesNeedUpdate = false
+        isConfiguring = false
+        setNeedsDisplay()
+    }
+
+    // MARK: - Private Methods
+
+    private func setTextStorage(_ newText: NSAttributedString?, shouldParse: Bool) {
+
+        guard let newText = newText, newText.length > 0 else {
+            textStorage.setAttributedString(NSAttributedString())
+            setNeedsDisplay()
+            return
+        }
+
+        let style = paragraphStyle(for: newText)
+        let range = NSRange(location: 0, length: newText.length)
+
+        let mutableText = NSMutableAttributedString(attributedString: newText)
+        mutableText.addAttribute(.paragraphStyle, value: style, range: range)
+
+        if shouldParse {
+            rangesForDetectors.removeAll()
+            let results = parse(text: mutableText)
+            setRangesForDetectors(in: results)
+        }
+
+        for (detector, rangeTuples) in rangesForDetectors {
+            if enabledDetectors.contains(detector) {
+                let attributes = detectorAttributes(for: detector)
+                rangeTuples.forEach { (range, _) in
+                    mutableText.addAttributes(attributes, range: range)
+                }
+            }
+        }
+
+        let modifiedText = NSAttributedString(attributedString: mutableText)
+        textStorage.setAttributedString(modifiedText)
+
+        if !isConfiguring { setNeedsDisplay() }
+
+    }
+
+    private func paragraphStyle(for text: NSAttributedString) -> NSParagraphStyle {
+        guard text.length > 0 else { return NSParagraphStyle() }
+
+        var range = NSRange(location: 0, length: text.length)
+        let existingStyle = text.attribute(.paragraphStyle, at: 0, effectiveRange: &range) as? NSMutableParagraphStyle
+        let style = existingStyle ?? NSMutableParagraphStyle()
+
+        style.lineBreakMode = lineBreakMode
+        style.alignment = textAlignment
+
+        return style
+    }
+
+    private func updateAttributes(for detectors: [DetectorType]) {
+
+        guard let attributedText = attributedText, attributedText.length > 0 else { return }
+        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText)
+
+        for detector in detectors {
+            guard let rangeTuples = rangesForDetectors[detector] else { continue }
+
+            for (range, _)  in rangeTuples {
+                let attributes = detectorAttributes(for: detector)
+                mutableAttributedString.addAttributes(attributes, range: range)
+            }
+
+            let updatedString = NSAttributedString(attributedString: mutableAttributedString)
+            textStorage.setAttributedString(updatedString)
+        }
+    }
+
+    private func detectorAttributes(for detectorType: DetectorType) -> [NSAttributedString.Key: Any] {
+
+        switch detectorType {
+        case .address:
+            return addressAttributes
+        case .date:
+            return dateAttributes
+        case .phoneNumber:
+            return phoneNumberAttributes
+        case .url:
+            return urlAttributes
+        case .transitInformation:
+            return transitInformationAttributes
+        case .mention:
+            return mentionAttributes
+        case .hashtag:
+            return hashtagAttributes
+        case .custom(let regex):
+            return customAttributes[regex] ?? MessageLabel.defaultAttributes
+        }
+
+    }
+
+    private func detectorAttributes(for checkingResultType: NSTextCheckingResult.CheckingType) -> [NSAttributedString.Key: Any] {
+        switch checkingResultType {
+        case .address:
+            return addressAttributes
+        case .date:
+            return dateAttributes
+        case .phoneNumber:
+            return phoneNumberAttributes
+        case .link:
+            return urlAttributes
+        case .transitInformation:
+            return transitInformationAttributes
+        default:
+            fatalError(MessageKitError.unrecognizedCheckingResult)
+        }
+    }
+
+    private func setupView() {
+        numberOfLines = 0
+        lineBreakMode = .byWordWrapping
+    }
+
+    // MARK: - Parsing Text
+
+    private func parse(text: NSAttributedString) -> [NSTextCheckingResult] {
+        guard enabledDetectors.isEmpty == false else { return [] }
+        let range = NSRange(location: 0, length: text.length)
+        var matches = [NSTextCheckingResult]()
+
+        // Get matches of all .custom DetectorType and add it to matches array
+        let regexs = enabledDetectors
+            .filter { $0.isCustom }
+            .map { parseForMatches(with: $0, in: text, for: range) }
+            .joined()
+        matches.append(contentsOf: regexs)
+
+        // Get all Checking Types of detectors, except for .custom because they contain their own regex
+        let detectorCheckingTypes = enabledDetectors
+            .filter { !$0.isCustom }
+            .reduce(0) { $0 | $1.textCheckingType.rawValue }
+        if detectorCheckingTypes > 0, let detector = try? NSDataDetector(types: detectorCheckingTypes) {
+            let detectorMatches = detector.matches(in: text.string, options: [], range: range)
+            matches.append(contentsOf: detectorMatches)
+        }
+
+        guard enabledDetectors.contains(.url) else {
+            return matches
+        }
+
+        // Enumerate NSAttributedString NSLinks and append ranges
+        var results: [NSTextCheckingResult] = matches
+
+        text.enumerateAttribute(NSAttributedString.Key.link, in: range, options: []) { value, range, _ in
+            guard let url = value as? URL else { return }
+            let result = NSTextCheckingResult.linkCheckingResult(range: range, url: url)
+            results.append(result)
+        }
+
+        return results
+    }
+
+    private func parseForMatches(with detector: DetectorType, in text: NSAttributedString, for range: NSRange) -> [NSTextCheckingResult] {
+        switch detector {
+        case .custom(let regex):
+            return regex.matches(in: text.string, options: [], range: range)
+        default:
+            fatalError("You must pass a .custom DetectorType")
+        }
+    }
+
+    private func setRangesForDetectors(in checkingResults: [NSTextCheckingResult]) {
+
+        guard checkingResults.isEmpty == false else { return }
+
+        for result in checkingResults {
+
+            switch result.resultType {
+            case .address:
+                var ranges = rangesForDetectors[.address] ?? []
+                let tuple: (NSRange, NewMessageTextCheckingType) = (result.range, .addressComponents(result.addressComponents))
+                ranges.append(tuple)
+                rangesForDetectors.updateValue(ranges, forKey: .address)
+            case .date:
+                var ranges = rangesForDetectors[.date] ?? []
+                let tuple: (NSRange, NewMessageTextCheckingType) = (result.range, .date(result.date))
+                ranges.append(tuple)
+                rangesForDetectors.updateValue(ranges, forKey: .date)
+            case .phoneNumber:
+                var ranges = rangesForDetectors[.phoneNumber] ?? []
+                let tuple: (NSRange, NewMessageTextCheckingType) = (result.range, .phoneNumber(result.phoneNumber))
+                ranges.append(tuple)
+                rangesForDetectors.updateValue(ranges, forKey: .phoneNumber)
+            case .link:
+                var ranges = rangesForDetectors[.url] ?? []
+                let tuple: (NSRange, NewMessageTextCheckingType) = (result.range, .link(result.url)) // schl#gt fehl
+                ranges.append(tuple)
+                rangesForDetectors.updateValue(ranges, forKey: .url)
+            case .transitInformation:
+                var ranges = rangesForDetectors[.transitInformation] ?? []
+                let tuple: (NSRange, NewMessageTextCheckingType) = (result.range, .transitInfoComponents(result.components))
+                ranges.append(tuple)
+                rangesForDetectors.updateValue(ranges, forKey: .transitInformation)
+            case .regularExpression:
+                guard let text = text, let regex = result.regularExpression, let range = Range(result.range, in: text) else { return }
+                let detector = DetectorType.custom(regex)
+                var ranges = rangesForDetectors[detector] ?? []
+                let tuple: (NSRange, NewMessageTextCheckingType) = (result.range, .custom(pattern: regex.pattern, match: String(text[range])))
+                ranges.append(tuple)
+                rangesForDetectors.updateValue(ranges, forKey: detector)
+            default:
+                fatalError("Received an unrecognized NSTextCheckingResult.CheckingType")
+            }
+
+        }
+
+    }
+
+    // MARK: - Gesture Handling
+
+    private func stringIndex(at location: CGPoint) -> Int? {
+        guard textStorage.length > 0 else { return nil }
+
+        var location = location
+
+        location.x -= textInsets.left
+        location.y -= textInsets.top
+
+        let index = layoutManager.glyphIndex(for: location, in: textContainer)
+
+        let lineRect = layoutManager.lineFragmentUsedRect(forGlyphAt: index, effectiveRange: nil)
+
+        var characterIndex: Int?
+
+        if lineRect.contains(location) {
+            characterIndex = layoutManager.characterIndexForGlyph(at: index)
+        }
+
+        return characterIndex
+
+    }
+
+  open func handleGesture(_ touchLocation: CGPoint) -> Bool {
+
+        guard let index = stringIndex(at: touchLocation) else { return false }
+
+        for (detectorType, ranges) in rangesForDetectors {
+            for (range, value) in ranges {
+                if range.contains(index) {
+                    handleGesture(for: detectorType, value: value)
+                    return true
+                }
+            }
+        }
+        return false
+    }
+
+    /// swiftlint:disable cyclomatic_complexity
+    private func handleGesture(for detectorType: DetectorType, value: NewMessageTextCheckingType) {
+        switch value {
+        case let .addressComponents(addressComponents):
+            var transformedAddressComponents = [String: String]()
+            guard let addressComponents = addressComponents else { return }
+            addressComponents.forEach { (key, value) in
+                transformedAddressComponents[key.rawValue] = value
+            }
+            handleAddress(transformedAddressComponents)
+        case let .phoneNumber(phoneNumber):
+            guard let phoneNumber = phoneNumber else { return }
+            handlePhoneNumber(phoneNumber)
+        case let .date(date):
+            guard let date = date else { return }
+            handleDate(date)
+        case let .link(url):
+            guard let url = url else { return }
+            handleURL(url)
+        case let .transitInfoComponents(transitInformation):
+            var transformedTransitInformation = [String: String]()
+            guard let transitInformation = transitInformation else { return }
+            transitInformation.forEach { (key, value) in
+                transformedTransitInformation[key.rawValue] = value
+            }
+            handleTransitInformation(transformedTransitInformation)
+        case let .custom(pattern, match):
+            guard let match = match else { return }
+            switch detectorType {
+            case .hashtag:
+                handleHashtag(match)
+            case .mention:
+                handleMention(match)
+            default:
+                handleCustom(pattern, match: match)
+            }
+        }
+    }
+    // swiftlint:enable cyclomatic_complexity
+
+    private func handleAddress(_ addressComponents: [String: String]) {
+        delegate?.didSelectAddress(addressComponents)
+    }
+
+    private func handleDate(_ date: Date) {
+        delegate?.didSelectDate(date)
+    }
+
+    private func handleURL(_ url: URL) {
+        delegate?.didSelectURL(url)
+    }
+
+    private func handlePhoneNumber(_ phoneNumber: String) {
+        delegate?.didSelectPhoneNumber(phoneNumber)
+    }
+
+    private func handleTransitInformation(_ components: [String: String]) {
+        delegate?.didSelectTransitInformation(components)
+    }
+
+    private func handleHashtag(_ hashtag: String) {
+        delegate?.didSelectHashtag(hashtag)
+    }
+
+    private func handleMention(_ mention: String) {
+        delegate?.didSelectMention(mention)
+    }
+
+    private func handleCustom(_ pattern: String, match: String) {
+        delegate?.didSelectCustom(pattern, match: match)
+    }
+
+}
+
+internal enum NewMessageTextCheckingType {
+    case addressComponents([NSTextCheckingKey: String]?)
+    case date(Date?)
+    case phoneNumber(String?)
+    case link(URL?)
+    case transitInfoComponents([NSTextCheckingKey: String]?)
+    case custom(pattern: String, match: String?)
+}

+ 61 - 36
deltachat-ios/View/PaddingTextView.swift

@@ -1,65 +1,90 @@
 import UIKit
 public class PaddingTextView: UIView {
 
-    public lazy var label: UILabel = {
-        let label = UILabel()
+    public lazy var label: NewMessageLabel = {
+        let label = NewMessageLabel()
         label.translatesAutoresizingMaskIntoConstraints = false
         label.numberOfLines = 0
         label.lineBreakMode = .byWordWrapping
+        label.isUserInteractionEnabled = true
         return label
     }()
 
-    let insets: UIEdgeInsets
+    public var paddingTop: CGFloat = 0 {
+        didSet { containerTopConstraint.constant = paddingTop }
+    }
+
+    public var paddingBottom: CGFloat = 0 {
+        didSet { containerBottomConstraint.constant = -paddingBottom }
+    }
+
+    public var paddingLeading: CGFloat = 0 {
+        didSet { containerLeadingConstraint.constant = paddingLeading }
+    }
+
+    public var paddingTrailing: CGFloat = 0 {
+        didSet { containerTailingConstraint.constant = -paddingTrailing }
+    }
+
+    private lazy var containerLeadingConstraint: NSLayoutConstraint = {
+        return label.constraintAlignLeadingTo(self)
+    }()
+    private lazy var containerTailingConstraint: NSLayoutConstraint = {
+        return label.constraintAlignTrailingTo(self)
+    }()
+    private lazy var containerTopConstraint: NSLayoutConstraint = {
+        return label.constraintAlignTopTo(self)
+    }()
+    private lazy var containerBottomConstraint: NSLayoutConstraint = {
+        return label.constraintAlignBottomTo(self)
+    }()
 
     public var text: String? {
-        set {
-            label.text = newValue
-        }
-        get {
-            return label.text
-        }
+        set { label.text = newValue }
+        get { return label.text }
     }
 
     public var attributedText: NSAttributedString? {
-        set {
-            label.attributedText = newValue
-        }
-        get {
-            return label.attributedText
-        }
+        set { label.attributedText = newValue }
+        get { return label.attributedText }
     }
 
     public var numberOfLines: Int {
-        set {
-            label.numberOfLines = newValue
-        }
-        get {
-            return label.numberOfLines
-        }
+        set { label.numberOfLines = newValue }
+        get { return label.numberOfLines }
     }
 
     public var font: UIFont {
-        set {
-            label.font = newValue
-        }
-        get {
-            return label.font
-        }
+        set { label.font = newValue }
+        get { return label.font }
     }
 
-    init(top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat) {
-        self.insets = UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)
-        super.init(frame: .zero)
-        setupView()
+    public var enabledDetectors: [DetectorType] {
+        set { label.enabledDetectors = newValue }
+        get { return label.enabledDetectors }
     }
 
-    func setupView() {
+    public var delegate: MessageLabelDelegate? {
+        set { label.delegate = newValue }
+        get { return label.delegate }
+    }
+
+    convenience init() {
+        self.init(top: 0, left: 0, bottom: 0, right: 0)
+    }
+    
+    init(top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat) {
+        super.init(frame: .zero)
         addSubview(label)
+        paddingLeading = left
+        paddingTop = top
+        paddingBottom = bottom
+        paddingTrailing = right
         addConstraints([
-            label.constraintAlignLeadingTo(self, paddingLeading: insets.left),
-            label.constraintAlignTrailingTo(self, paddingTrailing: insets.right),
-            label.constraintAlignTopTo(self, paddingTop: insets.top),
-            label.constraintAlignBottomTo(self, paddingBottom: insets.bottom)
+            containerTailingConstraint,
+            containerLeadingConstraint,
+            containerBottomConstraint,
+            containerTopConstraint
         ])
     }