Remove the first version of the AttributedString builder
Remvoe test stuf # Conflicts: # ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift # ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderV1.swift # ElementX/Sources/Other/HTMLParsing/DTHTMLElement+AttributedStringBuilder.swift
This commit is contained in:
@@ -160,12 +160,6 @@ 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
|
||||
|
||||
@@ -63,7 +63,6 @@ final class AppSettings {
|
||||
case knockingEnabled
|
||||
case threadsEnabled
|
||||
case developerOptionsEnabled
|
||||
case nextGenHTMLParserEnabled
|
||||
case linkPreviewsEnabled
|
||||
case latestEventSorterEnabled
|
||||
|
||||
@@ -389,10 +388,7 @@ final class AppSettings {
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.threadsEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var threadsEnabled
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.nextGenHTMLParserEnabled, defaultValue: true, storageType: .userDefaults(store))
|
||||
var nextGenHTMLParserEnabled
|
||||
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.linkPreviewsEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var linkPreviewsEnabled
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
// Copyright 2022-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 Foundation
|
||||
import LRUCache
|
||||
import MatrixRustSDK
|
||||
|
||||
protocol MentionBuilderProtocol {
|
||||
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?)
|
||||
func handleRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomID: String)
|
||||
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String, roomDisplayName: String?)
|
||||
func handleEventOnRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomAlias: String)
|
||||
func handleEventOnRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomID: String)
|
||||
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)
|
||||
static let CodeBlock: NSAttributedString.Key = .init(rawValue: CodeBlockAttribute.name)
|
||||
}
|
||||
|
||||
struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
||||
private static let defaultKey = "default"
|
||||
|
||||
private let builder: AttributedStringBuilderProtocol
|
||||
|
||||
static var useNextGenHTMLParser = false
|
||||
|
||||
static func invalidateCaches() {
|
||||
AttributedStringBuilderV1.invalidateCaches()
|
||||
AttributedStringBuilderV2.invalidateCaches()
|
||||
}
|
||||
|
||||
init(cacheKey: String = defaultKey, mentionBuilder: MentionBuilderProtocol) {
|
||||
if Self.useNextGenHTMLParser {
|
||||
builder = AttributedStringBuilderV2(cacheKey: cacheKey, mentionBuilder: mentionBuilder)
|
||||
} else {
|
||||
builder = AttributedStringBuilderV1(cacheKey: cacheKey, mentionBuilder: mentionBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
func fromPlain(_ string: String?) -> AttributedString? {
|
||||
builder.fromPlain(string)
|
||||
}
|
||||
|
||||
func fromHTML(_ htmlString: String?) -> AttributedString? {
|
||||
builder.fromHTML(htmlString)
|
||||
}
|
||||
|
||||
func addMatrixEntityPermalinkAttributesTo(_ attributedString: NSMutableAttributedString) {
|
||||
builder.addMatrixEntityPermalinkAttributesTo(attributedString)
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
// 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<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: originalHTMLString, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,31 @@ import MatrixRustSDK
|
||||
import SwiftSoup
|
||||
import UIKit
|
||||
|
||||
struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
|
||||
protocol MentionBuilderProtocol {
|
||||
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?)
|
||||
func handleRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomID: String)
|
||||
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String, roomDisplayName: String?)
|
||||
func handleEventOnRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomAlias: String)
|
||||
func handleEventOnRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomID: String)
|
||||
func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange)
|
||||
}
|
||||
|
||||
extension NSAttributedString.Key {
|
||||
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)
|
||||
static let CodeBlock: NSAttributedString.Key = .init(rawValue: CodeBlockAttribute.name)
|
||||
}
|
||||
|
||||
struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
||||
private static let defaultKey = "default"
|
||||
|
||||
private let cacheKey: String
|
||||
private let mentionBuilder: MentionBuilderProtocol
|
||||
|
||||
@@ -24,7 +48,7 @@ struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
|
||||
caches.removeAll()
|
||||
}
|
||||
|
||||
init(cacheKey: String, mentionBuilder: MentionBuilderProtocol) {
|
||||
init(cacheKey: String = defaultKey, mentionBuilder: MentionBuilderProtocol) {
|
||||
self.cacheKey = cacheKey
|
||||
self.mentionBuilder = mentionBuilder
|
||||
}
|
||||
@@ -475,3 +499,15 @@ private extension NSMutableAttributedString {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSString {
|
||||
func hasSuffixCharacter(from characterSet: CharacterSet) -> Bool {
|
||||
if length == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
let lastChar = character(at: length - 1)
|
||||
|
||||
return (characterSet as NSCharacterSet).characterIsMember(lastChar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
// Copyright 2022-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 DTCoreText
|
||||
import Foundation
|
||||
|
||||
public extension DTHTMLElement {
|
||||
/// Sanitize the element using the given parameters.
|
||||
/// - Parameters:
|
||||
/// - font: The default font to use when resetting the content of any unsupported tags.
|
||||
@objc func sanitize(font: UIFont) {
|
||||
if let name, !Self.allowedHTMLTags.contains(name) {
|
||||
// This is an unsupported tag.
|
||||
// Remove any attachments to fix rendering.
|
||||
textAttachment = nil
|
||||
|
||||
// Handle special case for span with data-mx-external-payment-details
|
||||
// This could be based on Storefront.current.countryCode to show the link
|
||||
// content in unrestricted countries. e.g. currently USA
|
||||
if name == "span",
|
||||
let attributes = attributes as? [String: String],
|
||||
attributes["data-msc4286-external-payment-details"] != nil {
|
||||
parent.removeChildNode(self)
|
||||
return
|
||||
}
|
||||
|
||||
// If the element has plain text content show that,
|
||||
// otherwise prevent the tag from displaying.
|
||||
if let stringContent = attributedString()?.string,
|
||||
!stringContent.isEmpty,
|
||||
let element = DTTextHTMLElement(name: nil, attributes: nil) {
|
||||
element.setText(stringContent)
|
||||
removeAllChildNodes()
|
||||
addChildNode(element)
|
||||
|
||||
if let parent = parent() {
|
||||
element.inheritAttributes(from: parent)
|
||||
} else {
|
||||
fontDescriptor = DTCoreTextFontDescriptor()
|
||||
fontDescriptor.fontFamily = font.familyName
|
||||
fontDescriptor.fontName = font.fontName
|
||||
fontDescriptor.pointSize = font.pointSize
|
||||
paragraphStyle = DTCoreTextParagraphStyle.default()
|
||||
|
||||
element.inheritAttributes(from: self)
|
||||
}
|
||||
element.interpretAttributes()
|
||||
|
||||
} else if let parent = parent() {
|
||||
parent.removeChildNode(self)
|
||||
} else {
|
||||
didOutput = true
|
||||
}
|
||||
|
||||
} else {
|
||||
// This element is a supported tag, but it may contain children that aren't,
|
||||
// so santize all child nodes to ensure correct tags.
|
||||
if let childNodes = childNodes as? [DTHTMLElement] {
|
||||
childNodes.forEach { $0.sanitize(font: font) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let allowedHTMLTags = ["font", // custom to matrix for IRC-style font coloring
|
||||
"del", // for markdown
|
||||
"body", // added internally by DTCoreText
|
||||
"mx-reply",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p", "a", "ul", "ol",
|
||||
"nl", "li", "b", "i", "u", "strong", "em", "strike", "code", "hr", "br", "div",
|
||||
"table", "thead", "caption", "tbody", "tr", "th", "td", "pre"]
|
||||
}
|
||||
@@ -53,7 +53,6 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
||||
var knockingEnabled: Bool { get set }
|
||||
var latestEventSorterEnabled: Bool { get set }
|
||||
|
||||
var nextGenHTMLParserEnabled: Bool { get set }
|
||||
var linkPreviewsEnabled: Bool { get set }
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,6 @@ struct DeveloperOptionsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("General") {
|
||||
Toggle(isOn: $context.nextGenHTMLParserEnabled) {
|
||||
Text("Next gen HTML parsing")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Room List") {
|
||||
Toggle(isOn: $context.publicSearchEnabled) {
|
||||
Text("Public search")
|
||||
|
||||
@@ -135,13 +135,8 @@ struct FormattedBodyText: View {
|
||||
|
||||
struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
body(AttributedStringBuilderV1(cacheKey: "v1", mentionBuilder: MentionBuilder()))
|
||||
body(AttributedStringBuilder(cacheKey: "FormattedBodyText", mentionBuilder: MentionBuilder()))
|
||||
.previewLayout(.sizeThatFits)
|
||||
.previewDisplayName("v1")
|
||||
|
||||
body(AttributedStringBuilderV2(cacheKey: "v2", mentionBuilder: MentionBuilder()))
|
||||
.previewLayout(.sizeThatFits)
|
||||
.previewDisplayName("v2")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
Reference in New Issue
Block a user