Add a V2 version of the AttributedStringBuilder as a verbatim copy of the original one (for now)

This commit is contained in:
Stefan Ceriu
2025-08-21 13:14:47 +03:00
committed by Doug
parent bd60a50a7e
commit 23a5d50067
5 changed files with 428 additions and 14 deletions

View File

@@ -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 = "<group>"; };
18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenViewModelTests.swift; sourceTree = "<group>"; };
18DB2F772E57262F003155CF /* AttributedStringBuilderV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderV1.swift; sourceTree = "<group>"; };
18DB2F7A2E5727B3003155CF /* AttributedStringBuilderV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderV2.swift; sourceTree = "<group>"; };
18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxy.swift; sourceTree = "<group>"; };
190EC7285D3CFEF0D3011BCF /* GeoURI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoURI.swift; sourceTree = "<group>"; };
196004E7695FBA292A7944AF /* ScreenTrackerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTrackerViewModifier.swift; sourceTree = "<group>"; };
@@ -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 */,

View File

@@ -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

View File

@@ -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? {

View File

@@ -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)

View File

@@ -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<String, AttributedString>] = [:]
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<String, AttributedString>(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 <blockquote> 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
}
}
}