mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
Reimplemented ObjC attributed string handling in swift.
This commit is contained in:
@@ -25,7 +25,6 @@
|
||||
18ADC7FA27EB02D900A8C953 /* AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18ADC7F927EB02D900A8C953 /* AttributedStringBuilder.swift */; };
|
||||
18ADC7FE27EB033400A8C953 /* DTCoreText in Frameworks */ = {isa = PBXBuildFile; productRef = 18ADC7FD27EB033400A8C953 /* DTCoreText */; };
|
||||
18ADC80627EB1ED100A8C953 /* UIFont+AttributedStringBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = 18ADC80427EB1ED100A8C953 /* UIFont+AttributedStringBuilder.m */; };
|
||||
18ADC80A27EB1F8B00A8C953 /* AttributedStringBuilderUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 18ADC80927EB1F8B00A8C953 /* AttributedStringBuilderUtils.m */; };
|
||||
18C5744C27E1D84000D70937 /* RoomProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5744827E1D84000D70937 /* RoomProxyProtocol.swift */; };
|
||||
18C5744D27E1D84000D70937 /* RoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5744A27E1D84000D70937 /* RoomProxy.swift */; };
|
||||
18C5744E27E1D84000D70937 /* MockRoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5744B27E1D84000D70937 /* MockRoomProxy.swift */; };
|
||||
@@ -37,6 +36,8 @@
|
||||
18DDB72327EC7702000F1ABF /* DTHTMLElement+AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DDB72227EC7702000F1ABF /* DTHTMLElement+AttributedStringBuilder.swift */; };
|
||||
18DDB72527EC784E000F1ABF /* FormattedBodyText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DDB72427EC784E000F1ABF /* FormattedBodyText.swift */; };
|
||||
18DDB72727EC78B8000F1ABF /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DDB72627EC78B8000F1ABF /* AttributedStringBuilderProtocol.swift */; };
|
||||
18DDB72E27EF936D000F1ABF /* MatrixEntitityRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DDB72D27EF936D000F1ABF /* MatrixEntitityRegex.swift */; };
|
||||
18DDB73227F19F82000F1ABF /* AttributedStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DDB73127F19F82000F1ABF /* AttributedStringBuilderTests.swift */; };
|
||||
18DF7C2F27E264FC00291672 /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C2E27E264FC00291672 /* MediaProvider.swift */; };
|
||||
18DF7C3127E3608100291672 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C3027E3608100291672 /* MediaProviderProtocol.swift */; };
|
||||
18DF7C3327E3608800291672 /* MockMediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C3227E3608800291672 /* MockMediaProvider.swift */; };
|
||||
@@ -149,8 +150,6 @@
|
||||
18ADC80427EB1ED100A8C953 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = "<group>"; };
|
||||
18ADC80527EB1ED100A8C953 /* UIFont+AttributedStringBuilder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIFont+AttributedStringBuilder.h"; sourceTree = "<group>"; };
|
||||
18ADC80727EB1EE200A8C953 /* ElementX-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ElementX-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
18ADC80827EB1F8B00A8C953 /* AttributedStringBuilderUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AttributedStringBuilderUtils.h; sourceTree = "<group>"; };
|
||||
18ADC80927EB1F8B00A8C953 /* AttributedStringBuilderUtils.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AttributedStringBuilderUtils.m; sourceTree = "<group>"; };
|
||||
18C5744827E1D84000D70937 /* RoomProxyProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomProxyProtocol.swift; sourceTree = "<group>"; };
|
||||
18C5744A27E1D84000D70937 /* RoomProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = "<group>"; };
|
||||
18C5744B27E1D84000D70937 /* MockRoomProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockRoomProxy.swift; sourceTree = "<group>"; };
|
||||
@@ -162,6 +161,8 @@
|
||||
18DDB72227EC7702000F1ABF /* DTHTMLElement+AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DTHTMLElement+AttributedStringBuilder.swift"; sourceTree = "<group>"; };
|
||||
18DDB72427EC784E000F1ABF /* FormattedBodyText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattedBodyText.swift; sourceTree = "<group>"; };
|
||||
18DDB72627EC78B8000F1ABF /* AttributedStringBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderProtocol.swift; sourceTree = "<group>"; };
|
||||
18DDB72D27EF936D000F1ABF /* MatrixEntitityRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MatrixEntitityRegex.swift; sourceTree = "<group>"; };
|
||||
18DDB73127F19F82000F1ABF /* AttributedStringBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderTests.swift; sourceTree = "<group>"; };
|
||||
18DF7C2E27E264FC00291672 /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = "<group>"; };
|
||||
18DF7C3027E3608100291672 /* MediaProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderProtocol.swift; sourceTree = "<group>"; };
|
||||
18DF7C3227E3608800291672 /* MockMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProvider.swift; sourceTree = "<group>"; };
|
||||
@@ -304,6 +305,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1850253E27B6918D002E6B18 /* ElementXTests.swift */,
|
||||
18DDB73127F19F82000F1ABF /* AttributedStringBuilderTests.swift */,
|
||||
);
|
||||
path = ElementXTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -348,8 +350,6 @@
|
||||
18ADC7F927EB02D900A8C953 /* AttributedStringBuilder.swift */,
|
||||
18ADC80527EB1ED100A8C953 /* UIFont+AttributedStringBuilder.h */,
|
||||
18ADC80427EB1ED100A8C953 /* UIFont+AttributedStringBuilder.m */,
|
||||
18ADC80827EB1F8B00A8C953 /* AttributedStringBuilderUtils.h */,
|
||||
18ADC80927EB1F8B00A8C953 /* AttributedStringBuilderUtils.m */,
|
||||
18DDB72027EB9D57000F1ABF /* ElementXAttributeScope.swift */,
|
||||
18DDB72227EC7702000F1ABF /* DTHTMLElement+AttributedStringBuilder.swift */,
|
||||
);
|
||||
@@ -483,6 +483,7 @@
|
||||
children = (
|
||||
18F2BA9A27D25B4000DD1988 /* Coordinator.swift */,
|
||||
18F2BA9427D25B4000DD1988 /* MXLog.swift */,
|
||||
18DDB72D27EF936D000F1ABF /* MatrixEntitityRegex.swift */,
|
||||
18F2BA8727D25B4000DD1988 /* Activity */,
|
||||
18ADC7F827EB02D900A8C953 /* HTMLParsing */,
|
||||
18F2BA7E27D25B4000DD1988 /* Routers */,
|
||||
@@ -945,6 +946,7 @@
|
||||
18DF7C5327E4754500291672 /* MemberDetailsProvider.swift in Sources */,
|
||||
18F2BAE227D25B4000DD1988 /* NavigationRouterStoreProtocol.swift in Sources */,
|
||||
18DDB72527EC784E000F1ABF /* FormattedBodyText.swift in Sources */,
|
||||
18DDB72E27EF936D000F1ABF /* MatrixEntitityRegex.swift in Sources */,
|
||||
18F2BB1727D25B4000DD1988 /* LoginScreenCoordinator.swift in Sources */,
|
||||
18F2BAF527D25B4000DD1988 /* WeakKeyDictionary.swift in Sources */,
|
||||
18F2BADF27D25B4000DD1988 /* NavigationRouterStore.swift in Sources */,
|
||||
@@ -972,7 +974,6 @@
|
||||
18F2BB0E27D25B4000DD1988 /* RoomScreenViewModelProtocol.swift in Sources */,
|
||||
18F2BB0D27D25B4000DD1988 /* RoomScreenViewModel.swift in Sources */,
|
||||
18C5745427E1D88E00D70937 /* TextRoomMessage.swift in Sources */,
|
||||
18ADC80A27EB1F8B00A8C953 /* AttributedStringBuilderUtils.m in Sources */,
|
||||
18C5745227E1D88600D70937 /* ImageRoomMessage.swift in Sources */,
|
||||
18F2BAE127D25B4000DD1988 /* RootRouterType.swift in Sources */,
|
||||
1850256C27B6A135002E6B18 /* AppCoordinator.swift in Sources */,
|
||||
@@ -990,6 +991,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
18F2BB2027D25D3400DD1988 /* RoomScreenUITests.swift in Sources */,
|
||||
18DDB73227F19F82000F1ABF /* AttributedStringBuilderTests.swift in Sources */,
|
||||
18F2BB1E27D25D2200DD1988 /* HomeScreenViewModelTests.swift in Sources */,
|
||||
18F2BB1D27D25D1C00DD1988 /* LoginScreenViewModelTests.swift in Sources */,
|
||||
1850253F27B6918D002E6B18 /* ElementXTests.swift in Sources */,
|
||||
|
||||
@@ -11,20 +11,53 @@ import DTCoreText
|
||||
|
||||
struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
||||
|
||||
private var defaultCSS: String {
|
||||
AttributedStringBuilderUtils.cssToMarkBlockquotes() +
|
||||
"""
|
||||
pre,code {
|
||||
background-color: #F5F7FA;
|
||||
display: inline;
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
-coretext-fontname: Menlo-Regular;
|
||||
private let temporaryBlockquoteMarkingColor = UIColor.magenta
|
||||
private let linkColor = UIColor.blue
|
||||
|
||||
private let userIdDetector: NSRegularExpression
|
||||
private let roomIdDetector: NSRegularExpression
|
||||
private let eventIdDetector: NSRegularExpression
|
||||
private let groupIdDetector: NSRegularExpression
|
||||
private let roomAliasDetector: NSRegularExpression
|
||||
private let linkDetector: NSDataDetector
|
||||
|
||||
init() {
|
||||
do {
|
||||
userIdDetector = try NSRegularExpression(pattern: MatrixEntityRegex.userId.rawValue, options: .caseInsensitive)
|
||||
roomIdDetector = try NSRegularExpression(pattern: MatrixEntityRegex.roomId.rawValue, options: .caseInsensitive)
|
||||
eventIdDetector = try NSRegularExpression(pattern: MatrixEntityRegex.eventId.rawValue, options: .caseInsensitive)
|
||||
groupIdDetector = try NSRegularExpression(pattern: MatrixEntityRegex.groupId.rawValue, options: .caseInsensitive)
|
||||
roomAliasDetector = try NSRegularExpression(pattern: MatrixEntityRegex.roomAlias.rawValue, options: .caseInsensitive)
|
||||
linkDetector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
} catch {
|
||||
fatalError()
|
||||
}
|
||||
h1,h2,h3 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
func fromPlain(_ string: String?) -> AttributedString? {
|
||||
guard let string = string else {
|
||||
return nil
|
||||
}
|
||||
"""
|
||||
|
||||
let mutableAttributedString = NSMutableAttributedString(string: string)
|
||||
|
||||
addLinkAttributesToAttributedString(mutableAttributedString)
|
||||
|
||||
return try? AttributedString(mutableAttributedString, including: \.elementX)
|
||||
}
|
||||
|
||||
func fromMarkdown(_ string: String?) -> AttributedString? {
|
||||
guard let string = string else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let mutableAttributedString = try? NSMutableAttributedString(markdown: string) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
addLinkAttributesToAttributedString(mutableAttributedString)
|
||||
|
||||
return try? AttributedString(mutableAttributedString, including: \.elementX)
|
||||
}
|
||||
|
||||
// Do not use the default HTML renderer of NSAttributedString because this method
|
||||
@@ -49,6 +82,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
||||
DTDefaultFontName: defaultFont.fontName,
|
||||
DTDefaultFontSize: defaultFont.pointSize,
|
||||
DTDefaultTextColor: defaultColor,
|
||||
DTDefaultLinkColor: linkColor,
|
||||
DTDefaultStyleSheet: DTCSSStylesheet(styleBlock: self.defaultCSS) as Any
|
||||
]
|
||||
|
||||
@@ -60,17 +94,19 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
||||
element?.sanitize(font: defaultFont)
|
||||
}
|
||||
|
||||
guard var nsAttributedString = builder.generatedAttributedString() else {
|
||||
guard let attributedString = builder.generatedAttributedString() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
nsAttributedString = AttributedStringBuilderUtils.removeDTCoreTextArtifacts(nsAttributedString)
|
||||
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
|
||||
|
||||
nsAttributedString = AttributedStringBuilderUtils.removeMarkedBlockquotesArtifacts(nsAttributedString)
|
||||
removeDTCoreTextArtifactsFromAttributedString(mutableAttributedString)
|
||||
|
||||
nsAttributedString = AttributedStringBuilderUtils.createLinks(nsAttributedString)
|
||||
replaceMarkedBlockquotes(mutableAttributedString)
|
||||
|
||||
return try? AttributedString(nsAttributedString, including: \.elementX)
|
||||
addLinkAttributesToAttributedString(mutableAttributedString)
|
||||
|
||||
return try? AttributedString(mutableAttributedString, including: \.elementX)
|
||||
}
|
||||
|
||||
func blockquoteCoalescedComponentsFrom(_ attributedString: AttributedString?) -> [AttributedStringBuilderComponent]? {
|
||||
@@ -83,4 +119,116 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
||||
isBlockquote: value != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func replaceMarkedBlockquotes(_ attributedString: NSMutableAttributedString) {
|
||||
// Enumerate all sections marked thanks to `cssToMarkBlockquotes`
|
||||
// and apply our own attribute instead.
|
||||
|
||||
// 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(.MXBlockquote, 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(.MXBlockquote, value: true, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeDTCoreTextArtifactsFromAttributedString(_ 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 func addLinkAttributesToAttributedString(_ attributedString: NSMutableAttributedString) {
|
||||
|
||||
let string = attributedString.string
|
||||
let range = NSRange(location: 0, length: attributedString.string.count)
|
||||
|
||||
var matches = userIdDetector.matches(in: string, options: [], range: range)
|
||||
matches.append(contentsOf: roomIdDetector.matches(in: string, options: [], range: range))
|
||||
matches.append(contentsOf: eventIdDetector.matches(in: string, options: [], range: range))
|
||||
matches.append(contentsOf: groupIdDetector.matches(in: string, options: [], range: range))
|
||||
matches.append(contentsOf: roomAliasDetector.matches(in: string, options: [], range: range))
|
||||
matches.append(contentsOf: linkDetector.matches(in: string, options: [], range: range))
|
||||
|
||||
guard matches.count > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
matches.forEach { match in
|
||||
guard let matchRange = Range(match.range, in: string) else {
|
||||
return
|
||||
}
|
||||
|
||||
let link = string[matchRange].addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
attributedString.addAttributes([NSAttributedString.Key.link: link as Any,
|
||||
NSAttributedString.Key.foregroundColor: linkColor],
|
||||
range: match.range)
|
||||
}
|
||||
}
|
||||
|
||||
private var cssToMarkBlockquotes: String {
|
||||
return "blockquote {background: \(temporaryBlockquoteMarkingColor.toHexString()); display: block;}"
|
||||
}
|
||||
|
||||
private var defaultCSS: String {
|
||||
cssToMarkBlockquotes +
|
||||
"""
|
||||
pre,code {
|
||||
background-color: #F5F7FA;
|
||||
display: inline;
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
-coretext-fontname: Menlo-Regular;
|
||||
}
|
||||
h1,h2,h3 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
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 = (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 MXBlockquote: NSAttributedString.Key = .init(rawValue: BlockquoteAttribute.name)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ struct AttributedStringBuilderComponent: Hashable {
|
||||
}
|
||||
|
||||
protocol AttributedStringBuilderProtocol {
|
||||
func fromPlain(_ string: String?) -> AttributedString?
|
||||
func fromHTML(_ htmlString: String?) -> AttributedString?
|
||||
func fromMarkdown(_ string: String?) -> AttributedString?
|
||||
|
||||
func blockquoteCoalescedComponentsFrom(_ attributedString: AttributedString?) -> [AttributedStringBuilderComponent]?
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
//
|
||||
// AttributedStringBuilderUtils.h
|
||||
// ElementX
|
||||
//
|
||||
// Created by Stefan Ceriu on 23/03/2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
FOUNDATION_EXPORT NSString *const kMXKToolsBlockquoteMarkAttribute;
|
||||
|
||||
@interface AttributedStringBuilderUtils : NSObject
|
||||
|
||||
+ (NSString*)cssToMarkBlockquotes;
|
||||
|
||||
+ (NSAttributedString *)removeDTCoreTextArtifacts:(NSAttributedString *)attributedString;
|
||||
|
||||
+ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString;
|
||||
|
||||
+ (NSAttributedString*)createLinks:(NSAttributedString*)attributedString;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,340 +0,0 @@
|
||||
//
|
||||
// AttributedStringBuilderUtils.m
|
||||
// ElementX
|
||||
//
|
||||
// Created by Stefan Ceriu on 23/03/2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
#import "AttributedStringBuilderUtils.h"
|
||||
@import DTCoreText;
|
||||
|
||||
// Temporary background color used to identify blockquote blocks with DTCoreText.
|
||||
#define kMXKToolsBlockquoteMarkColor [UIColor magentaColor]
|
||||
|
||||
// Attribute in an NSAttributeString that marks a blockquote block that was in the original HTML string.
|
||||
NSString *const kMXKToolsBlockquoteMarkAttribute = @"kMXKToolsBlockquoteMarkAttribute";
|
||||
|
||||
static NSRegularExpression *userIdRegex;
|
||||
static NSRegularExpression *roomIdRegex;
|
||||
static NSRegularExpression *roomAliasRegex;
|
||||
static NSRegularExpression *eventIdRegex;
|
||||
static NSRegularExpression *groupIdRegex;
|
||||
static NSRegularExpression *httpLinksRegex;
|
||||
static NSRegularExpression *htmlTagsRegex;
|
||||
|
||||
#define MATRIX_HOMESERVER_DOMAIN_REGEX @"[A-Z0-9]+((\\.|\\-)[A-Z0-9]+){0,}(:[0-9]{2,5})?"
|
||||
|
||||
NSString *const kMXToolsRegexStringForMatrixUserIdentifier = @"@[\\x21-\\x39\\x3B-\\x7F]+:" MATRIX_HOMESERVER_DOMAIN_REGEX;
|
||||
NSString *const kMXToolsRegexStringForMatrixRoomAlias = @"#[A-Z0-9._%#@=+-]+:" MATRIX_HOMESERVER_DOMAIN_REGEX;
|
||||
NSString *const kMXToolsRegexStringForMatrixRoomIdentifier = @"![A-Z0-9]+:" MATRIX_HOMESERVER_DOMAIN_REGEX;
|
||||
NSString *const kMXToolsRegexStringForMatrixEventIdentifier = @"\\$[A-Z0-9]+:" MATRIX_HOMESERVER_DOMAIN_REGEX;
|
||||
NSString *const kMXToolsRegexStringForMatrixEventIdentifierV3 = @"\\$[A-Z0-9\\/+]+";
|
||||
NSString *const kMXToolsRegexStringForMatrixGroupIdentifier = @"\\+[A-Z0-9=_\\-./]+:" MATRIX_HOMESERVER_DOMAIN_REGEX;
|
||||
|
||||
@implementation AttributedStringBuilderUtils
|
||||
|
||||
+ (void)initialize
|
||||
{
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
|
||||
userIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixUserIdentifier options:NSRegularExpressionCaseInsensitive error:nil];
|
||||
roomIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixRoomIdentifier options:NSRegularExpressionCaseInsensitive error:nil];
|
||||
roomAliasRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixRoomAlias options:NSRegularExpressionCaseInsensitive error:nil];
|
||||
eventIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixEventIdentifier options:NSRegularExpressionCaseInsensitive error:nil];
|
||||
groupIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixGroupIdentifier options:NSRegularExpressionCaseInsensitive error:nil];
|
||||
|
||||
httpLinksRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b(https?://\\S*)\\b" options:NSRegularExpressionCaseInsensitive error:nil];
|
||||
htmlTagsRegex = [NSRegularExpression regularExpressionWithPattern:@"<(\\w+)[^>]*>" options:NSRegularExpressionCaseInsensitive error:nil];
|
||||
});
|
||||
}
|
||||
|
||||
+ (NSString*)cssToMarkBlockquotes
|
||||
{
|
||||
return [NSString stringWithFormat:@"blockquote {background: #%lX; display: block;}", (unsigned long)[[self class] rgbValueWithColor:kMXKToolsBlockquoteMarkColor]];
|
||||
}
|
||||
|
||||
+ (NSUInteger)rgbValueWithColor:(UIColor*)color
|
||||
{
|
||||
CGFloat red, green, blue, alpha;
|
||||
|
||||
[color getRed:&red green:&green blue:&blue alpha:&alpha];
|
||||
|
||||
NSUInteger rgbValue = ((int)(red * 255) << 16) + ((int)(green * 255) << 8) + (blue * 255);
|
||||
|
||||
return rgbValue;
|
||||
}
|
||||
|
||||
+ (NSAttributedString *)removeDTCoreTextArtifacts:(NSAttributedString *)attributedString
|
||||
{
|
||||
NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString];
|
||||
|
||||
// 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 ([mutableAttributedString.string hasSuffixCharacterFromSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]])
|
||||
{
|
||||
[mutableAttributedString deleteCharactersInRange:NSMakeRange(mutableAttributedString.length - 1, 1)];
|
||||
}
|
||||
|
||||
// New lines may have also been introduced by the paragraph style
|
||||
// Make sure the last paragraph style has no spacing
|
||||
[mutableAttributedString enumerateAttributesInRange:NSMakeRange(0, mutableAttributedString.length) options:(NSAttributedStringEnumerationReverse) usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) {
|
||||
|
||||
if (attrs[NSParagraphStyleAttributeName])
|
||||
{
|
||||
NSString *subString = [mutableAttributedString.string substringWithRange:range];
|
||||
NSArray *components = [subString componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]];
|
||||
|
||||
NSMutableDictionary *updatedAttrs = [NSMutableDictionary dictionaryWithDictionary:attrs];
|
||||
NSMutableParagraphStyle *paragraphStyle = [updatedAttrs[NSParagraphStyleAttributeName] mutableCopy];
|
||||
paragraphStyle.paragraphSpacing = 0;
|
||||
updatedAttrs[NSParagraphStyleAttributeName] = paragraphStyle;
|
||||
|
||||
if (components.count > 1)
|
||||
{
|
||||
NSString *lastComponent = components.lastObject;
|
||||
|
||||
NSRange range2 = NSMakeRange(range.location, range.length - lastComponent.length);
|
||||
[mutableAttributedString setAttributes:attrs range:range2];
|
||||
|
||||
range2 = NSMakeRange(range2.location + range2.length, lastComponent.length);
|
||||
[mutableAttributedString setAttributes:updatedAttrs range:range2];
|
||||
}
|
||||
else
|
||||
{
|
||||
[mutableAttributedString setAttributes:updatedAttrs range:range];
|
||||
}
|
||||
}
|
||||
|
||||
// Check only the last paragraph
|
||||
*stop = YES;
|
||||
}];
|
||||
|
||||
// Image rendering failed on an exception until we replace the DTImageTextAttachments with a simple NSTextAttachment subclass
|
||||
// (thanks to https://github.com/Cocoanetics/DTCoreText/issues/863).
|
||||
[mutableAttributedString enumerateAttribute:NSAttachmentAttributeName
|
||||
inRange:NSMakeRange(0, mutableAttributedString.length)
|
||||
options:0
|
||||
usingBlock:^(id value, NSRange range, BOOL *stop) {
|
||||
|
||||
if ([value isKindOfClass:DTImageTextAttachment.class])
|
||||
{
|
||||
DTImageTextAttachment *attachment = (DTImageTextAttachment*)value;
|
||||
NSTextAttachment *textAttachment = [[NSTextAttachment alloc] init];
|
||||
if (attachment.image)
|
||||
{
|
||||
textAttachment.image = attachment.image;
|
||||
|
||||
CGRect frame = textAttachment.bounds;
|
||||
frame.size = attachment.displaySize;
|
||||
textAttachment.bounds = frame;
|
||||
}
|
||||
// Note we remove here attachment without image.
|
||||
NSAttributedString *attrStringWithImage = [NSAttributedString attributedStringWithAttachment:textAttachment];
|
||||
[mutableAttributedString replaceCharactersInRange:range withAttributedString:attrStringWithImage];
|
||||
}
|
||||
}];
|
||||
|
||||
return mutableAttributedString;
|
||||
}
|
||||
|
||||
+ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString
|
||||
{
|
||||
NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString];
|
||||
|
||||
// Enumerate all sections marked thanks to `cssToMarkBlockquotes`
|
||||
// and apply our own attribute instead.
|
||||
|
||||
// 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
|
||||
|
||||
// `DTTextBlocksAttribute` case
|
||||
[attributedString enumerateAttribute:DTTextBlocksAttribute
|
||||
inRange:NSMakeRange(0, attributedString.length)
|
||||
options:0
|
||||
usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop)
|
||||
{
|
||||
if ([value isKindOfClass:NSArray.class])
|
||||
{
|
||||
NSArray *array = (NSArray*)value;
|
||||
if (array.count > 0 && [array[0] isKindOfClass:DTTextBlock.class])
|
||||
{
|
||||
DTTextBlock *dtTextBlock = (DTTextBlock *)array[0];
|
||||
if ([dtTextBlock.backgroundColor isEqual:kMXKToolsBlockquoteMarkColor])
|
||||
{
|
||||
// Apply our own attribute
|
||||
[mutableAttributedString addAttribute:kMXKToolsBlockquoteMarkAttribute value:@(YES) range:range];
|
||||
|
||||
// Fix a boring behaviour where DTCoreText add a " " string before a string corresponding
|
||||
// to an HTML blockquote. This " " string has ParagraphStyle.headIndent = 0 which breaks
|
||||
// the blockquote block indentation
|
||||
if (range.location > 0)
|
||||
{
|
||||
NSRange prevRange = NSMakeRange(range.location - 1, 1);
|
||||
|
||||
NSRange effectiveRange;
|
||||
NSParagraphStyle *paragraphStyle = [attributedString attribute:NSParagraphStyleAttributeName
|
||||
atIndex:prevRange.location
|
||||
effectiveRange:&effectiveRange];
|
||||
|
||||
// Check if this is the " " string
|
||||
if (paragraphStyle && effectiveRange.length == 1 && paragraphStyle.firstLineHeadIndent != 25)
|
||||
{
|
||||
// Fix its paragraph style
|
||||
NSMutableParagraphStyle *newParagraphStyle = [paragraphStyle mutableCopy];
|
||||
newParagraphStyle.firstLineHeadIndent = 25.0;
|
||||
newParagraphStyle.headIndent = 25.0;
|
||||
|
||||
[mutableAttributedString addAttribute:NSParagraphStyleAttributeName value:newParagraphStyle range:prevRange];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
// `NSBackgroundColorAttributeName` case
|
||||
[mutableAttributedString enumerateAttribute:NSBackgroundColorAttributeName
|
||||
inRange:NSMakeRange(0, mutableAttributedString.length)
|
||||
options:0
|
||||
usingBlock:^(id value, NSRange range, BOOL *stop)
|
||||
{
|
||||
|
||||
if ([value isKindOfClass:UIColor.class] && [(UIColor*)value isEqual:kMXKToolsBlockquoteMarkColor])
|
||||
{
|
||||
// Remove the marked background
|
||||
[mutableAttributedString removeAttribute:NSBackgroundColorAttributeName range:range];
|
||||
|
||||
// And apply our own attribute
|
||||
[mutableAttributedString addAttribute:kMXKToolsBlockquoteMarkAttribute value:@(YES) range:range];
|
||||
}
|
||||
}];
|
||||
|
||||
return mutableAttributedString;
|
||||
}
|
||||
|
||||
+ (NSAttributedString*)createLinks:(NSAttributedString*)attributedString
|
||||
{
|
||||
if (!attributedString)
|
||||
{
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSMutableAttributedString *postRenderAttributedString;
|
||||
|
||||
[[self class] createHTTPLinksInAttributedString:attributedString withWorkingAttributedString:&postRenderAttributedString];
|
||||
[[self class] createLinksInAttributedString:attributedString matchingRegex:userIdRegex withWorkingAttributedString:&postRenderAttributedString];
|
||||
[[self class] createLinksInAttributedString:attributedString matchingRegex:roomIdRegex withWorkingAttributedString:&postRenderAttributedString];
|
||||
[[self class] createLinksInAttributedString:attributedString matchingRegex:roomAliasRegex withWorkingAttributedString:&postRenderAttributedString];
|
||||
[[self class] createLinksInAttributedString:attributedString matchingRegex:eventIdRegex withWorkingAttributedString:&postRenderAttributedString];
|
||||
[[self class] createLinksInAttributedString:attributedString matchingRegex:groupIdRegex withWorkingAttributedString:&postRenderAttributedString];
|
||||
|
||||
return postRenderAttributedString ? postRenderAttributedString : attributedString;
|
||||
}
|
||||
|
||||
+ (void)createHTTPLinksInAttributedString:(NSAttributedString*)attributedString
|
||||
withWorkingAttributedString:(NSMutableAttributedString* __autoreleasing *)mutableAttributedString
|
||||
{
|
||||
// Enumerate each string matching the regex
|
||||
[httpLinksRegex enumerateMatchesInString:attributedString.string options:0 range:NSMakeRange(0, attributedString.length) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop) {
|
||||
|
||||
// Do not create a link if there is already one on the found match
|
||||
__block BOOL hasAlreadyLink = NO;
|
||||
[attributedString enumerateAttributesInRange:match.range options:0 usingBlock:^(NSDictionary<NSString *,id> * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) {
|
||||
|
||||
if (attrs[NSLinkAttributeName])
|
||||
{
|
||||
hasAlreadyLink = YES;
|
||||
*stop = YES;
|
||||
}
|
||||
}];
|
||||
|
||||
if (!hasAlreadyLink)
|
||||
{
|
||||
// Create the output string only if it is necessary because attributed strings cost CPU
|
||||
if (!*mutableAttributedString)
|
||||
{
|
||||
*mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString];
|
||||
}
|
||||
|
||||
// Make the link clickable
|
||||
// Caution: We need here to escape the non-ASCII characters (like '#' in room alias)
|
||||
// to convert the link into a legal URL string.
|
||||
NSString *link = [attributedString.string substringWithRange:match.range];
|
||||
link = [link stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]];
|
||||
[*mutableAttributedString addAttribute:NSLinkAttributeName value:link range:match.range];
|
||||
[*mutableAttributedString addAttribute:NSForegroundColorAttributeName value:UIColor.blueColor range:match.range];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
+ (void)createLinksInAttributedString:(NSAttributedString*)attributedString
|
||||
matchingRegex:(NSRegularExpression*)regex
|
||||
withWorkingAttributedString:(NSMutableAttributedString* __autoreleasing *)mutableAttributedString
|
||||
{
|
||||
__block NSArray *linkMatches;
|
||||
|
||||
// Enumerate each string matching the regex
|
||||
[regex enumerateMatchesInString:attributedString.string options:0 range:NSMakeRange(0, attributedString.length) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop) {
|
||||
|
||||
// Do not create a link if there is already one on the found match
|
||||
__block BOOL hasAlreadyLink = NO;
|
||||
[attributedString enumerateAttributesInRange:match.range options:0 usingBlock:^(NSDictionary<NSString *,id> * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) {
|
||||
|
||||
if (attrs[NSLinkAttributeName])
|
||||
{
|
||||
hasAlreadyLink = YES;
|
||||
*stop = YES;
|
||||
}
|
||||
}];
|
||||
|
||||
// Do not create a link if the match is part of an http link.
|
||||
// The http link will be automatically generated by the UI afterwards.
|
||||
// So, do not break it now by adding a link on a subset of this http link.
|
||||
if (!hasAlreadyLink)
|
||||
{
|
||||
if (!linkMatches)
|
||||
{
|
||||
// Search for the links in the string only once
|
||||
// Do not use NSDataDetector with NSTextCheckingTypeLink because is not able to
|
||||
// manage URLs with 2 hashes like "https://matrix.to/#/#matrix:matrix.org"
|
||||
// Such URL is not valid but web browsers can open them and users C+P them...
|
||||
// NSDataDetector does not support it but UITextView and UIDataDetectorTypeLink
|
||||
// detect them when they are displayed. So let the UI create the link at display.
|
||||
linkMatches = [httpLinksRegex matchesInString:attributedString.string options:0 range:NSMakeRange(0, attributedString.length)];
|
||||
}
|
||||
|
||||
for (NSTextCheckingResult *linkMatch in linkMatches)
|
||||
{
|
||||
// If the match is fully in the link, skip it
|
||||
if (NSIntersectionRange(match.range, linkMatch.range).length == match.range.length)
|
||||
{
|
||||
hasAlreadyLink = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAlreadyLink)
|
||||
{
|
||||
// Create the output string only if it is necessary because attributed strings cost CPU
|
||||
if (!*mutableAttributedString)
|
||||
{
|
||||
*mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString];
|
||||
}
|
||||
|
||||
// Make the link clickable
|
||||
// Caution: We need here to escape the non-ASCII characters (like '#' in room alias)
|
||||
// to convert the link into a legal URL string.
|
||||
NSString *link = [attributedString.string substringWithRange:match.range];
|
||||
link = [link stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]];
|
||||
[*mutableAttributedString addAttribute:NSLinkAttributeName value:link range:match.range];
|
||||
[*mutableAttributedString addAttribute:NSForegroundColorAttributeName value:UIColor.blueColor range:match.range];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -10,7 +10,7 @@ import Foundation
|
||||
|
||||
enum BlockquoteAttribute: AttributedStringKey {
|
||||
typealias Value = Bool
|
||||
public static var name = kMXKToolsBlockquoteMarkAttribute
|
||||
public static var name = "MXBlockquoteAttribute"
|
||||
}
|
||||
|
||||
extension AttributeScopes {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// MatrixEntitityRegex.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Stefan Ceriu on 26/03/2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MatrixEntityRegex: String {
|
||||
case homeserver
|
||||
case userId
|
||||
case roomAlias
|
||||
case roomId
|
||||
case eventId
|
||||
case groupId
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .homeserver:
|
||||
return "[A-Z0-9]+((\\.|\\-)[A-Z0-9]+){0,}(:[0-9]{2,5})?"
|
||||
case .userId:
|
||||
return "@[\\x21-\\x39\\x3B-\\x7F]+:" + MatrixEntityRegex.homeserver.rawValue
|
||||
case .roomAlias:
|
||||
return "#[A-Z0-9._%#@=+-]+:" + MatrixEntityRegex.homeserver.rawValue
|
||||
case .roomId:
|
||||
return "![A-Z0-9]+:" + MatrixEntityRegex.homeserver.rawValue
|
||||
case .eventId:
|
||||
return "\\$[A-Z0-9]+:" + MatrixEntityRegex.homeserver.rawValue
|
||||
case .groupId:
|
||||
return "\\+[A-Z0-9=_\\-./]+:" + MatrixEntityRegex.homeserver.rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ struct RoomTimelineItemFactory {
|
||||
|
||||
switch roomMessage {
|
||||
case let message as TextRoomMessage:
|
||||
let attributedText = attributedStringBuilder.fromHTML(message.htmlBody ?? message.body)
|
||||
|
||||
let attributedText = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
|
||||
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
|
||||
|
||||
return TextRoomTimelineItem(id: message.id,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
#import "AttributedStringBuilderUtils.h"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// AttributedStringBuilderTests.swift
|
||||
// ElementXTests
|
||||
//
|
||||
// Created by Stefan Ceriu on 28/03/2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import ElementX
|
||||
|
||||
class AttributedStringBuilderTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user