diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 72afcbf2a..48b795fc2 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -148,6 +148,8 @@ 18386B777FDA74E4B3282D4F /* TimelineItemThreadSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */; }; 18867F4F1C8991EEC56EA932 /* UTType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897DF5E9A70CE05A632FC8AF /* UTType.swift */; }; 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 */; }; 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 */; }; @@ -1607,6 +1609,7 @@ 18486B87745B1811E7FBD3D2 /* AnalyticsPromptScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenModels.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -5041,6 +5044,7 @@ 8F9A844EB44B6AD7CA18FD96 /* HTMLParsing */ = { isa = PBXGroup; children = ( + 18DB2F772E57262F003155CF /* AttributedStringBuilderV1.swift */, 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */, 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */, 1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */, @@ -7178,6 +7182,7 @@ 761EA50B2619307AB30891B8 /* PhishingDetector.swift in Sources */, 5DFC2A889D3B39DD47AC63A8 /* PillUtilities.swift in Sources */, 55CDD3968D95D1A820B5491E /* PlaceholderAvatarImage.swift in Sources */, + 18DB2F782E572636003155CF /* AttributedStringBuilderV1.swift in Sources */, F12F6BED7B6D7EE4BEE55039 /* PlainMentionBuilder.swift in Sources */, 76C874243A8C440D6CF7B344 /* ProcessInfo.swift in Sources */, 88272A714D81BC507F76A1EE /* RageshakeConfiguration.swift in Sources */, @@ -7672,6 +7677,7 @@ F3F9D61C53C348043D3D6F51 /* EncryptionResetScreen.swift in Sources */, 3041EBA2660F28FFB7BDA339 /* EncryptionResetScreenCoordinator.swift in Sources */, 97BAEDD9054FB5F233EE928B /* EncryptionResetScreenModels.swift in Sources */, + 18DB2F792E572636003155CF /* AttributedStringBuilderV1.swift in Sources */, EC3320639828BED8B3E5F2C6 /* EncryptionResetScreenViewModel.swift in Sources */, A0868BDE84D2140A885BE3C9 /* EncryptionResetScreenViewModelProtocol.swift in Sources */, 4D2B54233C7B2C04B4ABE55A /* EncryptionSettingsFlowCoordinator.swift in Sources */, diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index c43c5c9ce..76c84cd6f 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -47,402 +47,3 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { builder.addMatrixEntityPermalinkAttributesTo(attributedString) } } - -private struct AttributedStringBuilderV1: 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_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 - } -} - -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) - 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 - } - } -} diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV1.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV1.swift new file mode 100644 index 000000000..f94471440 --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV1.swift @@ -0,0 +1,411 @@ +// +// 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 AttributedStringBuilderV1: 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_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 + } +} + +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) + 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 + } + } +}