From 23a5d500671bded19a7ba910d239d92c490a9196 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 21 Aug 2025 13:14:47 +0300 Subject: [PATCH] Add a V2 version of the AttributedStringBuilder as a verbatim copy of the original one (for now) --- ElementX.xcodeproj/project.pbxproj | 6 + .../Sources/Application/AppCoordinator.swift | 6 + .../HTMLParsing/AttributedStringBuilder.swift | 19 +- .../AttributedStringBuilderV1.swift | 13 - .../AttributedStringBuilderV2.swift | 398 ++++++++++++++++++ 5 files changed, 428 insertions(+), 14 deletions(-) create mode 100644 ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV2.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 48b795fc2..c2297d618 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -150,6 +150,8 @@ 18978C9438206828C1D5AF2A /* test_animated_image.gif in Resources */ = {isa = PBXBuildFile; fileRef = 53FD6D3D38F556CEAA280C58 /* test_animated_image.gif */; }; 18DB2F782E572636003155CF /* AttributedStringBuilderV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB2F772E57262F003155CF /* AttributedStringBuilderV1.swift */; }; 18DB2F792E572636003155CF /* AttributedStringBuilderV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB2F772E57262F003155CF /* AttributedStringBuilderV1.swift */; }; + 18DB2F7B2E5727B3003155CF /* AttributedStringBuilderV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB2F7A2E5727B3003155CF /* AttributedStringBuilderV2.swift */; }; + 18DB2F7C2E5727B3003155CF /* AttributedStringBuilderV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB2F7A2E5727B3003155CF /* AttributedStringBuilderV2.swift */; }; 18E3786918486D4C9726BC84 /* FormButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FBFC09F9DAFF1E4BA97849 /* FormButtonStyles.swift */; }; 18FDE4ED6D83B0771452B43D /* RoomSelectionScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F104596B0620CEFE5DFD31B1 /* RoomSelectionScreenCoordinator.swift */; }; 192A3CDCD0174AD1E4A128E4 /* AudioRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2441E2424E78A40FC95DBA76 /* AudioRecorderTests.swift */; }; @@ -1610,6 +1612,7 @@ 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorationTimelineItemProtocol.swift; sourceTree = ""; }; 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenViewModelTests.swift; sourceTree = ""; }; 18DB2F772E57262F003155CF /* AttributedStringBuilderV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderV1.swift; sourceTree = ""; }; + 18DB2F7A2E5727B3003155CF /* AttributedStringBuilderV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderV2.swift; sourceTree = ""; }; 18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxy.swift; sourceTree = ""; }; 190EC7285D3CFEF0D3011BCF /* GeoURI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoURI.swift; sourceTree = ""; }; 196004E7695FBA292A7944AF /* ScreenTrackerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTrackerViewModifier.swift; sourceTree = ""; }; @@ -5045,6 +5048,7 @@ isa = PBXGroup; children = ( 18DB2F772E57262F003155CF /* AttributedStringBuilderV1.swift */, + 18DB2F7A2E5727B3003155CF /* AttributedStringBuilderV2.swift */, 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */, 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */, 1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */, @@ -7156,6 +7160,7 @@ 59F940FCBE6BC343AECEF75E /* ImageCache.swift in Sources */, EBE13FAB4E29738AC41BD3E5 /* InfoPlistReader.swift in Sources */, 8691186F9B99BCDDB7CACDD8 /* KeychainController.swift in Sources */, + 18DB2F7C2E5727B3003155CF /* AttributedStringBuilderV2.swift in Sources */, A440D4BC02088482EC633A88 /* KeychainControllerProtocol.swift in Sources */, FB0A9D06FC9122E37992D962 /* LayoutDirection.swift in Sources */, 0728314DD51AC3819F818EA8 /* LogLevel.swift in Sources */, @@ -8372,6 +8377,7 @@ 4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */, 4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */, 4870F7F72CEBEFDAD1C973A6 /* VoiceMessageMediaEventsTimelineView.swift in Sources */, + 18DB2F7B2E5727B3003155CF /* AttributedStringBuilderV2.swift in Sources */, 386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */, 9DE801D278AC34737467F937 /* VoiceMessageMediaManagerProtocol.swift in Sources */, F7932A3F075B0D3F24DEECB5 /* VoiceMessagePreviewComposer.swift in Sources */, diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 6c73bbfd2..e8173ad13 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -162,6 +162,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } .store(in: &cancellables) + appSettings.$nextGenHTMLParserEnabled + .sink { value in + AttributedStringBuilder.useNextGenHTMLParser = value + } + .store(in: &cancellables) + elementCallService.actions .receive(on: DispatchQueue.main) .sink { [weak self] action in diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index 76c84cd6f..69b3ffb7b 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -20,6 +20,19 @@ protocol MentionBuilderProtocol { func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) } +extension NSAttributedString.Key { + static let DTTextBlocks: NSAttributedString.Key = .init(rawValue: DTTextBlocksAttribute) + static let MatrixBlockquote: NSAttributedString.Key = .init(rawValue: BlockquoteAttribute.name) + static let MatrixUserID: NSAttributedString.Key = .init(rawValue: UserIDAttribute.name) + static let MatrixUserDisplayName: NSAttributedString.Key = .init(rawValue: UserDisplayNameAttribute.name) + static let MatrixRoomDisplayName: NSAttributedString.Key = .init(rawValue: RoomDisplayNameAttribute.name) + static let MatrixRoomID: NSAttributedString.Key = .init(rawValue: RoomIDAttribute.name) + static let MatrixRoomAlias: NSAttributedString.Key = .init(rawValue: RoomAliasAttribute.name) + static let MatrixEventOnRoomID: NSAttributedString.Key = .init(rawValue: EventOnRoomIDAttribute.name) + static let MatrixEventOnRoomAlias: NSAttributedString.Key = .init(rawValue: EventOnRoomAliasAttribute.name) + static let MatrixAllUsersMention: NSAttributedString.Key = .init(rawValue: AllUsersMentionAttribute.name) +} + struct AttributedStringBuilder: AttributedStringBuilderProtocol { private static let defaultKey = "default" @@ -32,7 +45,11 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { } init(cacheKey: String = defaultKey, mentionBuilder: MentionBuilderProtocol) { - builder = AttributedStringBuilderV1(cacheKey: cacheKey, mentionBuilder: mentionBuilder) + if Self.useNextGenHTMLParser { + builder = AttributedStringBuilderV2(cacheKey: cacheKey, mentionBuilder: mentionBuilder) + } else { + builder = AttributedStringBuilderV1(cacheKey: cacheKey, mentionBuilder: mentionBuilder) + } } func fromPlain(_ string: String?) -> AttributedString? { diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV1.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV1.swift index f94471440..4d194e80b 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV1.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV1.swift @@ -373,19 +373,6 @@ private extension UIColor { } } -extension NSAttributedString.Key { - static let DTTextBlocks: NSAttributedString.Key = .init(rawValue: DTTextBlocksAttribute) - static let MatrixBlockquote: NSAttributedString.Key = .init(rawValue: BlockquoteAttribute.name) - static let MatrixUserID: NSAttributedString.Key = .init(rawValue: UserIDAttribute.name) - static let MatrixUserDisplayName: NSAttributedString.Key = .init(rawValue: UserDisplayNameAttribute.name) - static let MatrixRoomDisplayName: NSAttributedString.Key = .init(rawValue: RoomDisplayNameAttribute.name) - static let MatrixRoomID: NSAttributedString.Key = .init(rawValue: RoomIDAttribute.name) - static let MatrixRoomAlias: NSAttributedString.Key = .init(rawValue: RoomAliasAttribute.name) - static let MatrixEventOnRoomID: NSAttributedString.Key = .init(rawValue: EventOnRoomIDAttribute.name) - static let MatrixEventOnRoomAlias: NSAttributedString.Key = .init(rawValue: EventOnRoomAliasAttribute.name) - static let MatrixAllUsersMention: NSAttributedString.Key = .init(rawValue: AllUsersMentionAttribute.name) -} - private struct TextParsingMatch { enum MatchType { case userID(identifier: String) diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV2.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV2.swift new file mode 100644 index 000000000..a9e2b63d6 --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV2.swift @@ -0,0 +1,398 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import DTCoreText +import LRUCache +import MatrixRustSDK +import UIKit + +struct AttributedStringBuilderV2: AttributedStringBuilderProtocol { + private let cacheKey: String + private let temporaryBlockquoteMarkingColor = UIColor.magenta + private let temporaryCodeBlockMarkingColor = UIColor.cyan + private let mentionBuilder: MentionBuilderProtocol + + private static let cacheDispatchQueue = DispatchQueue(label: "io.element.elementx.attributed_string_builder_v2_cache") + private static var caches: [String: LRUCache] = [:] + + static func invalidateCaches() { + caches.removeAll() + } + + init(cacheKey: String, mentionBuilder: MentionBuilderProtocol) { + self.cacheKey = cacheKey + self.mentionBuilder = mentionBuilder + } + + func fromPlain(_ string: String?) -> AttributedString? { + guard let string else { + return nil + } + + if let cached = Self.cachedValue(forKey: string, cacheKey: cacheKey) { + return cached + } + + let mutableAttributedString = NSMutableAttributedString(string: string) + removeDefaultForegroundColors(mutableAttributedString) + addLinksAndMentions(mutableAttributedString) + addMatrixEntityPermalinkAttributesTo(mutableAttributedString) + + let result = try? AttributedString(mutableAttributedString, including: \.elementX) + Self.cacheValue(result, forKey: string, cacheKey: cacheKey) + + return result + } + + // Do not use the default HTML renderer of NSAttributedString because this method + // runs on the UI thread which we want to avoid because renderHTMLString is called + // most of the time from a background thread. + // Use DTCoreText HTML renderer instead. + // Using DTCoreText, which renders static string, helps to avoid code injection attacks + // that could happen with the default HTML renderer of NSAttributedString which is a + // webview. + func fromHTML(_ htmlString: String?) -> AttributedString? { + guard let originalHTMLString = htmlString else { + return nil + } + + if let cached = Self.cachedValue(forKey: originalHTMLString, cacheKey: cacheKey) { + return cached + } + + let htmlString = originalHTMLString.replacingHtmlBreaksOccurrences() + + guard let data = htmlString.data(using: .utf8) else { + return nil + } + + let defaultFont = UIFont.preferredFont(forTextStyle: .body) + + let parsingOptions: [String: Any] = [ + DTUseiOS6Attributes: true, + DTDefaultFontFamily: defaultFont.familyName, + DTDefaultFontName: defaultFont.fontName, + DTDefaultFontSize: defaultFont.pointSize, + DTDefaultStyleSheet: DTCSSStylesheet(styleBlock: defaultCSS) as Any, + DTDefaultLinkDecoration: false + ] + + guard let builder = DTHTMLAttributedStringBuilder(html: data, options: parsingOptions, documentAttributes: nil) else { + return nil + } + + builder.willFlushCallback = { element in + element?.sanitize(font: defaultFont) + } + + guard let attributedString = builder.generatedAttributedString() else { + return nil + } + + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + removeDefaultForegroundColors(mutableAttributedString) + detectPhishingAttempts(mutableAttributedString) + addLinksAndMentions(mutableAttributedString) + replaceMarkedBlockquotes(mutableAttributedString) + replaceMarkedCodeBlocks(mutableAttributedString) + addMatrixEntityPermalinkAttributesTo(mutableAttributedString) + removeDTCoreTextArtifacts(mutableAttributedString) + + let result = try? AttributedString(mutableAttributedString, including: \.elementX) + Self.cacheValue(result, forKey: htmlString, cacheKey: cacheKey) + + return result + } + + // MARK: - Private + + private static func cacheValue(_ value: AttributedString?, forKey key: String, cacheKey: String) { + cacheDispatchQueue.sync { + if caches[cacheKey] == nil { + caches[cacheKey] = LRUCache(countLimit: 1000) + } + + caches[cacheKey]?.setValue(value, forKey: key) + } + } + + private static func cachedValue(forKey key: String, cacheKey: String) -> AttributedString? { + var result: AttributedString? + cacheDispatchQueue.sync { + result = caches[cacheKey]?.value(forKey: key) + } + + return result + } + + private func removeDefaultForegroundColors(_ attributedString: NSMutableAttributedString) { + attributedString.removeAttribute(.foregroundColor, range: .init(location: 0, length: attributedString.length)) + } + + // swiftlint:disable:next cyclomatic_complexity + private func addLinksAndMentions(_ attributedString: NSMutableAttributedString) { + let string = attributedString.string + + // Event identifiers and room aliases and identifiers detected in plain text are techincally incomplete + // without via parameters and we won't bother detecting them + + var matches: [TextParsingMatch] = MatrixEntityRegex.userIdentifierRegex.matches(in: string).compactMap { match in + guard let matchRange = Range(match.range, in: string) else { + return nil + } + + let identifier = String(string[matchRange]) + + return TextParsingMatch(type: .userID(identifier: identifier), range: match.range) + } + + matches.append(contentsOf: MatrixEntityRegex.roomAliasRegex.matches(in: string).compactMap { match in + guard let matchRange = Range(match.range, in: string) else { + return nil + } + + let alias = String(string[matchRange]) + + return TextParsingMatch(type: .roomAlias(alias: alias), range: match.range) + }) + + matches.append(contentsOf: MatrixEntityRegex.uriRegex.matches(in: string).compactMap { match in + guard let matchRange = Range(match.range, in: string) else { + return nil + } + + let uri = String(string[matchRange]) + + return TextParsingMatch(type: .matrixURI(uri: uri), range: match.range) + }) + + matches.append(contentsOf: MatrixEntityRegex.linkRegex.matches(in: string).compactMap { match in + guard let matchRange = Range(match.range, in: string) else { + return nil + } + + let link = String(string[matchRange]).asSanitizedLink + return TextParsingMatch(type: .link(urlString: link), range: match.range) + }) + + matches.append(contentsOf: MatrixEntityRegex.allUsersRegex.matches(in: attributedString.string).map { match in + TextParsingMatch(type: .atRoom, range: match.range) + }) + + guard matches.count > 0 else { + return + } + + // Sort the links by length so the longest one always takes priority + matches.sorted { $0.range.length > $1.range.length }.forEach { [attributedString] match in + var hasLink = false + attributedString.enumerateAttribute(.link, in: match.range, options: []) { value, _, stop in + if value != nil { + hasLink = true + stop.pointee = true + } + } + + if hasLink { + return + } + + // Don't add any extra attributes within codeblocks + if attributedString.attribute(.backgroundColor, at: match.range.location, effectiveRange: nil) as? UIColor == temporaryCodeBlockMarkingColor { + return + } + + switch match.type { + case .atRoom: + attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range) + case .roomAlias(let alias): + if let urlString = try? matrixToRoomAliasPermalink(roomAlias: alias), + let url = URL(string: urlString) { + attributedString.addAttribute(.link, value: url, range: match.range) + } + case .matrixURI(let uri): + if let url = URL(string: uri) { + attributedString.addAttribute(.link, value: url, range: match.range) + } + case .userID, .link: + if let url = match.link { + attributedString.addAttribute(.link, value: url, range: match.range) + } + } + } + } + + private func replaceMarkedBlockquotes(_ attributedString: NSMutableAttributedString) { + // According to blockquotes in the string, DTCoreText can apply 2 policies: + // - define a `DTTextBlocksAttribute` attribute on a
block + // - or, just define a `NSBackgroundColorAttributeName` attribute + attributedString.enumerateAttribute(.DTTextBlocks, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in + guard let value = value as? NSArray, + let dtTextBlock = value.firstObject as? DTTextBlock, + dtTextBlock.backgroundColor == temporaryBlockquoteMarkingColor else { + return + } + + attributedString.addAttribute(.MatrixBlockquote, value: true, range: range) + } + + attributedString.enumerateAttribute(.backgroundColor, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in + guard let value = value as? UIColor, + value == temporaryBlockquoteMarkingColor else { + return + } + + attributedString.removeAttribute(.backgroundColor, range: range) + attributedString.addAttribute(.MatrixBlockquote, value: true, range: range) + } + } + + private func replaceMarkedCodeBlocks(_ attributedString: NSMutableAttributedString) { + attributedString.enumerateAttribute(.backgroundColor, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in + if let value = value as? UIColor, + value == temporaryCodeBlockMarkingColor { + attributedString.addAttribute(.backgroundColor, value: UIColor.compound._bgCodeBlock as Any, range: range) + attributedString.removeAttribute(.link, range: range) + } + } + } + + func addMatrixEntityPermalinkAttributesTo(_ attributedString: NSMutableAttributedString) { + attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in + if value != nil { + if let url = value as? URL, + let matrixEntity = parseMatrixEntityFrom(uri: url.absoluteString) { + switch matrixEntity.id { + case .user(let userID): + mentionBuilder.handleUserMention(for: attributedString, in: range, url: url, userID: userID, userDisplayName: nil) + case .room(let roomID): + mentionBuilder.handleRoomIDMention(for: attributedString, in: range, url: url, roomID: roomID) + case .roomAlias(let alias): + mentionBuilder.handleRoomAliasMention(for: attributedString, in: range, url: url, roomAlias: alias, roomDisplayName: nil) + case .eventOnRoomId(let roomID, let eventID): + mentionBuilder.handleEventOnRoomIDMention(for: attributedString, in: range, url: url, eventID: eventID, roomID: roomID) + case .eventOnRoomAlias(let alias, let eventID): + mentionBuilder.handleEventOnRoomAliasMention(for: attributedString, in: range, url: url, eventID: eventID, roomAlias: alias) + } + } + } + } + + attributedString.enumerateAttribute(.MatrixAllUsersMention, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in + if let value = value as? Bool, + value { + mentionBuilder.handleAllUsersMention(for: attributedString, in: range) + } + } + } + + private func detectPhishingAttempts(_ attributedString: NSMutableAttributedString) { + attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in + guard value != nil, let internalURL = value as? URL else { + return + } + let displayString = attributedString.attributedSubstring(from: range).string + + guard PhishingDetector.isPhishingAttempt(displayString: displayString, internalURL: internalURL) else { + return + } + handlePhishingAttempt(for: attributedString, in: range, internalURL: internalURL, displayString: displayString) + } + } + + private func handlePhishingAttempt(for attributedString: NSMutableAttributedString, + in range: NSRange, + internalURL: URL, + displayString: String) { + // Let's remove the existing link attribute + attributedString.removeAttribute(.link, range: range) + + var urlComponents = URLComponents() + urlComponents.scheme = URL.confirmationScheme + urlComponents.host = "" + let parameters = ConfirmURLParameters(internalURL: internalURL, displayString: displayString) + urlComponents.queryItems = parameters.urlQueryItems + + guard let finalURL = urlComponents.url else { + return + } + + attributedString.addAttribute(.link, value: finalURL, range: range) + } + + private func removeDTCoreTextArtifacts(_ attributedString: NSMutableAttributedString) { + guard attributedString.length > 0 else { + return + } + + // DTCoreText adds a newline at the end of plain text ( https://github.com/Cocoanetics/DTCoreText/issues/779 ) + // or after a blockquote section. + // Trim trailing whitespace and newlines in the string content + while (attributedString.string as NSString).hasSuffixCharacter(from: .whitespacesAndNewlines) { + attributedString.deleteCharacters(in: .init(location: attributedString.length - 1, length: 1)) + } + } + + private var defaultCSS: String { + """ + blockquote { + background: \(temporaryBlockquoteMarkingColor.toHexString()); + display: block; + } + pre,code { + background-color: \(temporaryCodeBlockMarkingColor.toHexString()); + display: inline; + white-space: pre; + font-size: 0.9em; + -coretext-fontname: .AppleSystemUIFontMonospaced-Regular; + } + h1,h2,h3 { + font-size: 1.2em; + } + """ + } +} + +private extension UIColor { + func toHexString() -> String { + var red: CGFloat = 0.0 + var green: CGFloat = 0.0 + var blue: CGFloat = 0.0 + var alpha: CGFloat = 0.0 + + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + let rgb = Int(red * 255) << 16 | Int(green * 255) << 8 | Int(blue * 255) << 0 + + return NSString(format: "#%06x", rgb) as String + } +} + +private struct TextParsingMatch { + enum MatchType { + case userID(identifier: String) + case roomAlias(alias: String) + case matrixURI(uri: String) + case link(urlString: String) + case atRoom + } + + let type: MatchType + let range: NSRange + + var link: URL? { + switch type { + case .userID(let identifier): + return try? URL(string: matrixToUserPermalink(userId: identifier)) + case .link(let urlString): + return URL(string: urlString) + default: + return nil + } + } +}