mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Move the initial attributed string builder to its own file
This commit is contained in:
@@ -148,6 +148,8 @@
|
|||||||
18386B777FDA74E4B3282D4F /* TimelineItemThreadSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */; };
|
18386B777FDA74E4B3282D4F /* TimelineItemThreadSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */; };
|
||||||
18867F4F1C8991EEC56EA932 /* UTType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897DF5E9A70CE05A632FC8AF /* UTType.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 */; };
|
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 */; };
|
18E3786918486D4C9726BC84 /* FormButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FBFC09F9DAFF1E4BA97849 /* FormButtonStyles.swift */; };
|
||||||
18FDE4ED6D83B0771452B43D /* RoomSelectionScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F104596B0620CEFE5DFD31B1 /* RoomSelectionScreenCoordinator.swift */; };
|
18FDE4ED6D83B0771452B43D /* RoomSelectionScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F104596B0620CEFE5DFD31B1 /* RoomSelectionScreenCoordinator.swift */; };
|
||||||
192A3CDCD0174AD1E4A128E4 /* AudioRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2441E2424E78A40FC95DBA76 /* AudioRecorderTests.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 = "<group>"; };
|
18486B87745B1811E7FBD3D2 /* AnalyticsPromptScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenModels.swift; sourceTree = "<group>"; };
|
||||||
184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorationTimelineItemProtocol.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
||||||
18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxy.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>"; };
|
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>"; };
|
196004E7695FBA292A7944AF /* ScreenTrackerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTrackerViewModifier.swift; sourceTree = "<group>"; };
|
||||||
@@ -5041,6 +5044,7 @@
|
|||||||
8F9A844EB44B6AD7CA18FD96 /* HTMLParsing */ = {
|
8F9A844EB44B6AD7CA18FD96 /* HTMLParsing */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
18DB2F772E57262F003155CF /* AttributedStringBuilderV1.swift */,
|
||||||
2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */,
|
2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */,
|
||||||
72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */,
|
72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */,
|
||||||
1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */,
|
1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */,
|
||||||
@@ -7178,6 +7182,7 @@
|
|||||||
761EA50B2619307AB30891B8 /* PhishingDetector.swift in Sources */,
|
761EA50B2619307AB30891B8 /* PhishingDetector.swift in Sources */,
|
||||||
5DFC2A889D3B39DD47AC63A8 /* PillUtilities.swift in Sources */,
|
5DFC2A889D3B39DD47AC63A8 /* PillUtilities.swift in Sources */,
|
||||||
55CDD3968D95D1A820B5491E /* PlaceholderAvatarImage.swift in Sources */,
|
55CDD3968D95D1A820B5491E /* PlaceholderAvatarImage.swift in Sources */,
|
||||||
|
18DB2F782E572636003155CF /* AttributedStringBuilderV1.swift in Sources */,
|
||||||
F12F6BED7B6D7EE4BEE55039 /* PlainMentionBuilder.swift in Sources */,
|
F12F6BED7B6D7EE4BEE55039 /* PlainMentionBuilder.swift in Sources */,
|
||||||
76C874243A8C440D6CF7B344 /* ProcessInfo.swift in Sources */,
|
76C874243A8C440D6CF7B344 /* ProcessInfo.swift in Sources */,
|
||||||
88272A714D81BC507F76A1EE /* RageshakeConfiguration.swift in Sources */,
|
88272A714D81BC507F76A1EE /* RageshakeConfiguration.swift in Sources */,
|
||||||
@@ -7672,6 +7677,7 @@
|
|||||||
F3F9D61C53C348043D3D6F51 /* EncryptionResetScreen.swift in Sources */,
|
F3F9D61C53C348043D3D6F51 /* EncryptionResetScreen.swift in Sources */,
|
||||||
3041EBA2660F28FFB7BDA339 /* EncryptionResetScreenCoordinator.swift in Sources */,
|
3041EBA2660F28FFB7BDA339 /* EncryptionResetScreenCoordinator.swift in Sources */,
|
||||||
97BAEDD9054FB5F233EE928B /* EncryptionResetScreenModels.swift in Sources */,
|
97BAEDD9054FB5F233EE928B /* EncryptionResetScreenModels.swift in Sources */,
|
||||||
|
18DB2F792E572636003155CF /* AttributedStringBuilderV1.swift in Sources */,
|
||||||
EC3320639828BED8B3E5F2C6 /* EncryptionResetScreenViewModel.swift in Sources */,
|
EC3320639828BED8B3E5F2C6 /* EncryptionResetScreenViewModel.swift in Sources */,
|
||||||
A0868BDE84D2140A885BE3C9 /* EncryptionResetScreenViewModelProtocol.swift in Sources */,
|
A0868BDE84D2140A885BE3C9 /* EncryptionResetScreenViewModelProtocol.swift in Sources */,
|
||||||
4D2B54233C7B2C04B4ABE55A /* EncryptionSettingsFlowCoordinator.swift in Sources */,
|
4D2B54233C7B2C04B4ABE55A /* EncryptionSettingsFlowCoordinator.swift in Sources */,
|
||||||
|
|||||||
@@ -47,402 +47,3 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
builder.addMatrixEntityPermalinkAttributesTo(attributedString)
|
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<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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user