diff --git a/CHANGELOG.md b/CHANGELOG.md index 4602998d..9e0b1329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ The changelog for `MessageKit`. Also see the [releases](https://github.com/Messa ### Added +- **Breaking Change** Added `.hashtag`, .`mention` to detect theses pattern inside the `messageLabel`. We also add `.custom(pattern: YOUR_PATTERN)` to `DetectorType` to manage and deal with your own regular expression. +[#913](https://github.com/MessageKit/MessageKit/pull/913) by [@JulienKode](https://github.com/julienkode). + - Added support for detection and handling of `NSLink`s inside of messages. [#815](https://github.com/MessageKit/MessageKit/pull/815) by [@jnic](https://github.com/jnic) diff --git a/Example/Sources/Data Generation/SampleData.swift b/Example/Sources/Data Generation/SampleData.swift index 63614a73..50b3dfa3 100644 --- a/Example/Sources/Data Generation/SampleData.swift +++ b/Example/Sources/Data Generation/SampleData.swift @@ -46,7 +46,7 @@ final internal class SampleData { let messageImages: [UIImage] = [#imageLiteral(resourceName: "img1"), #imageLiteral(resourceName: "img2")] - let messageTypes = ["Text", "Text", "Text", "AttributedText", "Location", "Photo", "Emoji", "Video", "URL", "Phone", "Custom"] + let messageTypes = ["Text", "Text", "Text", "AttributedText", "Location", "Photo", "Emoji", "Video", "URL", "Phone", "Mention", "Hashtag", "Custom"] let emojis = [ "👍", @@ -150,6 +150,10 @@ final internal class SampleData { return MockMessage(text: "https://github.com/MessageKit", sender: sender, messageId: uniqueID, date: date) case "Phone": return MockMessage(text: "123-456-7890", sender: sender, messageId: uniqueID, date: date) + case "Mention": + return MockMessage(text: "@messagekit", sender: sender, messageId: uniqueID, date: date) + case "Hashtag": + return MockMessage(text: "#messagekit", sender: sender, messageId: uniqueID, date: date) case "Custom": return MockMessage(custom: "Someone left the conversation", sender: system, messageId: uniqueID, date: date) default: diff --git a/Example/Sources/View Controllers/AdvancedExampleViewController.swift b/Example/Sources/View Controllers/AdvancedExampleViewController.swift index 32c8761f..3f3c8432 100644 --- a/Example/Sources/View Controllers/AdvancedExampleViewController.swift +++ b/Example/Sources/View Controllers/AdvancedExampleViewController.swift @@ -258,11 +258,14 @@ extension AdvancedExampleViewController: MessagesDisplayDelegate { } func detectorAttributes(for detector: DetectorType, and message: MessageType, at indexPath: IndexPath) -> [NSAttributedString.Key: Any] { - return MessageLabel.defaultAttributes + switch detector { + case .hashtag, .mention: return [.foregroundColor: UIColor.blue] + default: return MessageLabel.defaultAttributes + } } func enabledDetectors(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> [DetectorType] { - return [.url, .address, .phoneNumber, .date, .transitInformation] + return [.url, .address, .phoneNumber, .date, .transitInformation, .mention, .hashtag] } // MARK: - All Messages diff --git a/Example/Sources/View Controllers/BasicExampleViewController.swift b/Example/Sources/View Controllers/BasicExampleViewController.swift index 0d7fc717..b2fd0e96 100644 --- a/Example/Sources/View Controllers/BasicExampleViewController.swift +++ b/Example/Sources/View Controllers/BasicExampleViewController.swift @@ -49,11 +49,14 @@ extension BasicExampleViewController: MessagesDisplayDelegate { } func detectorAttributes(for detector: DetectorType, and message: MessageType, at indexPath: IndexPath) -> [NSAttributedString.Key: Any] { - return MessageLabel.defaultAttributes + switch detector { + case .hashtag, .mention: return [.foregroundColor: UIColor.blue] + default: return MessageLabel.defaultAttributes + } } func enabledDetectors(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> [DetectorType] { - return [.url, .address, .phoneNumber, .date, .transitInformation] + return [.url, .address, .phoneNumber, .date, .transitInformation, .mention, .hashtag] } // MARK: - All Messages diff --git a/Sources/Models/DetectorType.swift b/Sources/Models/DetectorType.swift index a49b7b6d..d3d43f2e 100644 --- a/Sources/Models/DetectorType.swift +++ b/Sources/Models/DetectorType.swift @@ -24,19 +24,19 @@ import Foundation -public enum DetectorType { +public enum DetectorType: Hashable { case address case date case phoneNumber case url case transitInformation + case custom(regex: NSRegularExpression) - // MARK: - Not supported yet - - //case mention - //case hashtag - //case custom + // swiftlint:disable force_try + public static var hashtag = DetectorType.custom(regex: try! NSRegularExpression(pattern: "#[a-zA-Z0-9]{4,}", options: [])) + public static var mention = DetectorType.custom(regex: try! NSRegularExpression(pattern: "@[a-zA-Z0-9]{4,}", options: [])) + // swiftlint:enable force_try internal var textCheckingType: NSTextCheckingResult.CheckingType { switch self { @@ -45,6 +45,32 @@ public enum DetectorType { case .phoneNumber: return .phoneNumber case .url: return .link case .transitInformation: return .transitInformation + case .custom: return .regularExpression + } + } + + /// Simply check if the detector type is a .custom + public var isCustom: Bool { + switch self { + case .custom: return true + default: return false + } + } + + ///The hashValue of the `DetectorType` so we can conform to `Hashable` and be sorted. + public var hashValue: Int { + return self.toInt() + } + + /// Return an 'Int' value for each `DetectorType` type so `DetectorType` can conform to `Hashable` + private func toInt() -> Int { + switch self { + case .address: return 0 + case .date: return 1 + case .phoneNumber: return 2 + case .url: return 3 + case .transitInformation: return 4 + case .custom(let regex): return regex.hashValue } } diff --git a/Sources/Protocols/MessageLabelDelegate.swift b/Sources/Protocols/MessageLabelDelegate.swift index e84ea622..46f5759d 100644 --- a/Sources/Protocols/MessageLabelDelegate.swift +++ b/Sources/Protocols/MessageLabelDelegate.swift @@ -56,6 +56,25 @@ public protocol MessageLabelDelegate: AnyObject { /// - Parameters: /// - transitInformation: The selected transit information. func didSelectTransitInformation(_ transitInformation: [String: String]) + + /// Triggered when a tap occurs on a mention + /// + /// - Parameters: + /// - mention: The selected mention + func didSelectMention(_ mention: String) + + /// Triggered when a tap occurs on a hashtag + /// + /// - Parameters: + /// - mention: The selected hashtag + func didSelectHashtag(_ hashtag: String) + + /// Triggered when a tap occurs on a custom regular expression + /// + /// - Parameters: + /// - pattern: the pattern of the regular expression + /// - match: part that match with the regular expression + func didSelectCustom(_ pattern: String, match: String?) } @@ -71,4 +90,10 @@ public extension MessageLabelDelegate { func didSelectTransitInformation(_ transitInformation: [String: String]) {} + func didSelectMention(_ mention: String) {} + + func didSelectHashtag(_ hashtag: String) {} + + func didSelectCustom(_ pattern: String, match: String?) {} + } diff --git a/Sources/Views/MessageLabel.swift b/Sources/Views/MessageLabel.swift index 9b04d5bd..d72a0ffc 100644 --- a/Sources/Views/MessageLabel.swift +++ b/Sources/Views/MessageLabel.swift @@ -49,7 +49,7 @@ open class MessageLabel: UILabel { return textStorage }() - private lazy var rangesForDetectors: [DetectorType: [(NSRange, MessageTextCheckingType)]] = [:] + internal lazy var rangesForDetectors: [DetectorType: [(NSRange, MessageTextCheckingType)]] = [:] private var isConfiguring: Bool = false @@ -134,6 +134,12 @@ open class MessageLabel: UILabel { 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 { @@ -147,6 +153,12 @@ open class MessageLabel: UILabel { 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 @@ -276,6 +288,12 @@ open class MessageLabel: UILabel { return urlAttributes case .transitInformation: return transitInformationAttributes + case .mention: + return mentionAttributes + case .hashtag: + return hashtagAttributes + case .custom(let regex): + return customAttributes[regex] ?? MessageLabel.defaultAttributes } } @@ -301,10 +319,24 @@ open class MessageLabel: UILabel { private func parse(text: NSAttributedString) -> [NSTextCheckingResult] { guard enabledDetectors.isEmpty == false else { return [] } - let checkingTypes = enabledDetectors.reduce(0) { $0 | $1.textCheckingType.rawValue } - let detector = try? NSDataDetector(types: checkingTypes) let range = NSRange(location: 0, length: text.length) - let matches = detector?.matches(in: text.string, options: [], range: range) ?? [] + 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 @@ -322,6 +354,24 @@ open class MessageLabel: UILabel { return results } + /** + Take a custom detector and apply the matching to the text + + - Parameters: detector: `DetectorType` that you want to execute + - Parameters: text: where we apply the regular expression + - Parameters: range: where we apply the regular expression + + - Returns: an array of `NSTextCheckingResult` that contains all maching for this detector or nil if it fails + */ + 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 } @@ -354,7 +404,13 @@ open class MessageLabel: UILabel { let tuple: (NSRange, MessageTextCheckingType) = (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: regex) + var ranges = rangesForDetectors[detector] ?? [] + let tuple: (NSRange, MessageTextCheckingType) = (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") } @@ -428,6 +484,16 @@ open class MessageLabel: UILabel { 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: + handleHashtag(match) + default: + handleCustom(pattern, match: match) + } } } @@ -450,13 +516,26 @@ open class MessageLabel: UILabel { 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) + } + } -private enum MessageTextCheckingType { +internal enum MessageTextCheckingType { case addressComponents([NSTextCheckingKey: String]?) case date(Date?) case phoneNumber(String?) case link(URL?) case transitInfoComponents([NSTextCheckingKey: String]?) + case custom(pattern: String, match: String?) } diff --git a/Tests/ControllersTest/MessageLabelSpec.swift b/Tests/ControllersTest/MessageLabelSpec.swift index abf28a00..5d62e52f 100644 --- a/Tests/ControllersTest/MessageLabelSpec.swift +++ b/Tests/ControllersTest/MessageLabelSpec.swift @@ -38,7 +38,90 @@ final class MessageLabelSpec: QuickSpec { messageLabel = MessageLabel() } -// describe("text recognized by a DetectorType") { + describe("text recognized by a DetectorType") { + + let mentionsList = ["@julienkode", "@facebook", "@google", "@1234"] + let hashtagsList = ["#julienkode", "#facebook", "#google", "#1234"] + + var detector: DetectorType! + var key: NSAttributedString.Key! + var attributes: [NSAttributedString.Key: Any]! + + context("Mention detection") { + + beforeEach { + detector = DetectorType.mention + key = NSAttributedString.Key(rawValue: "Mention") + attributes = [key: "MentionDetected"] + } + + it("match with multiples alpha and numerics") { + let text = mentionsList.joined(separator: " #test ") + self.set(text: text, and: [detector], with: attributes, to: messageLabel) + let matches = self.extractCustomDetectors(for: detector, with: messageLabel) + expect(matches).to(equal(mentionsList)) + } + + it("is invalid") { + let invalids = hashtagsList.joined(separator: " ") + self.set(text: invalids, and: [detector], with: attributes, to: messageLabel) + let matches = self.extractCustomDetectors(for: detector, with: messageLabel) + expect(matches.count).to(equal(0)) + } + + } + + context("Hashtag detection") { + + beforeEach { + detector = DetectorType.hashtag + key = NSAttributedString.Key(rawValue: "Hashtag") + attributes = [key: "HashtagDetected"] + } + + it("match with multiples alpha and numerics") { + let text = hashtagsList.joined(separator: " @test ") + self.set(text: text, and: [detector], with: attributes, to: messageLabel) + let matches = self.extractCustomDetectors(for: detector, with: messageLabel) + expect(matches).to(equal(hashtagsList)) + } + + it("is invalid") { + let invalids = mentionsList.joined(separator: " ") + self.set(text: invalids, and: [detector], with: attributes, to: messageLabel) + let matches = self.extractCustomDetectors(for: detector, with: messageLabel) + expect(matches.count).to(equal(0)) + } + + } + + context("Custom detection") { + + let shouldPass = ["1234", "1", "09876"] + let shouldFailed = ["abcd", "a", "!!!", ";"] + + beforeEach { + detector = DetectorType.custom(regex: try! NSRegularExpression(pattern: "[0-9]+", options: .caseInsensitive)) + key = NSAttributedString.Key(rawValue: "Custom") + attributes = [key: "CustomDetected"] + } + + it("must match with one or more numerics") { + let text = shouldPass.joined(separator: " ") + self.set(text: text, and: [detector], with: attributes, to: messageLabel) + let matches = self.extractCustomDetectors(for: detector, with: messageLabel) + expect(matches).to(equal(shouldPass)) + } + + it("must failed with any non numerics characters") { + let invalids = shouldFailed.joined(separator: " ") + self.set(text: invalids, and: [detector], with: attributes, to: messageLabel) + let matches = self.extractCustomDetectors(for: detector, with: messageLabel) + expect(matches.count).to(equal(0)) + } + + } + // context("address detection is enabled") { // it("applies addressAttributes to text") { // let expectedColor = UIColor.blue @@ -83,7 +166,7 @@ final class MessageLabelSpec: QuickSpec { // expect(textFont).to(equal(expectedFont)) // } // } -// } + } describe("the synchronization between text and attributedText") { context("when attributedText is set to a non-nil value") { @@ -237,6 +320,44 @@ final class MessageLabelSpec: QuickSpec { } } } + + // MARK: - Private helpers API for Detectors + + /** + Takes a given `DetectorType` and extract matches from a `MessageLabel` + + - Parameters: detector: `DetectorType` that you want to extract + - Parameters: label: `MessageLabel` where you want to get matches + + - Returns: an array of `String` that contains all matches for the given detector + */ + private func extractCustomDetectors(for detector: DetectorType, with label: MessageLabel) -> [String] { + guard let detection = label.rangesForDetectors[detector] else { return [] } + return detection.compactMap ({ (range, messageChecking) -> String? in + switch messageChecking { + case .custom(_, let match): + return match + default: + return nil + } + }) + } + + /** + Simply set text, detectors and attriutes to a given label + + - Parameters: text: `String` that will be applied to the label + - Parameters: detector: `DetectorType` that you want to apply to the label + - Parameters: attributes: `[NSAttributedString.Key: Any]` that you want to apply to the label + - Parameters: label: `MessageLabel` that takes the previous parameters + + */ + private func set(text: String, and detectors: [DetectorType], with attributes: [NSAttributedString.Key: Any], to label: MessageLabel) { + label.mentionAttributes = attributes + label.enabledDetectors = detectors + label.text = text + } + } // MARK: - Helpers