diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 38ad7c323..ffa91beee 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 18ADC80527EB1ED100A8C953 /* UIFont+AttributedStringBuilder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIFont+AttributedStringBuilder.h"; sourceTree = ""; }; 18ADC80727EB1EE200A8C953 /* ElementX-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ElementX-Bridging-Header.h"; sourceTree = ""; }; - 18ADC80827EB1F8B00A8C953 /* AttributedStringBuilderUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AttributedStringBuilderUtils.h; sourceTree = ""; }; - 18ADC80927EB1F8B00A8C953 /* AttributedStringBuilderUtils.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AttributedStringBuilderUtils.m; sourceTree = ""; }; 18C5744827E1D84000D70937 /* RoomProxyProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomProxyProtocol.swift; sourceTree = ""; }; 18C5744A27E1D84000D70937 /* RoomProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; 18C5744B27E1D84000D70937 /* MockRoomProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockRoomProxy.swift; sourceTree = ""; }; @@ -162,6 +161,8 @@ 18DDB72227EC7702000F1ABF /* DTHTMLElement+AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DTHTMLElement+AttributedStringBuilder.swift"; sourceTree = ""; }; 18DDB72427EC784E000F1ABF /* FormattedBodyText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattedBodyText.swift; sourceTree = ""; }; 18DDB72627EC78B8000F1ABF /* AttributedStringBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderProtocol.swift; sourceTree = ""; }; + 18DDB72D27EF936D000F1ABF /* MatrixEntitityRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MatrixEntitityRegex.swift; sourceTree = ""; }; + 18DDB73127F19F82000F1ABF /* AttributedStringBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderTests.swift; sourceTree = ""; }; 18DF7C2E27E264FC00291672 /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = ""; }; 18DF7C3027E3608100291672 /* MediaProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderProtocol.swift; sourceTree = ""; }; 18DF7C3227E3608800291672 /* MockMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProvider.swift; sourceTree = ""; }; @@ -304,6 +305,7 @@ isa = PBXGroup; children = ( 1850253E27B6918D002E6B18 /* ElementXTests.swift */, + 18DDB73127F19F82000F1ABF /* AttributedStringBuilderTests.swift */, ); path = ElementXTests; sourceTree = ""; @@ -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 */, diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index b62c065a2..bad9e616f 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -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
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) } diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift index 6030dc4fb..4c253454a 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift @@ -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]? } diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h deleted file mode 100644 index 84c3970b7..000000000 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// AttributedStringBuilderUtils.h -// ElementX -// -// Created by Stefan Ceriu on 23/03/2022. -// Copyright © 2022 Element. All rights reserved. -// - -#import - -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 diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m deleted file mode 100644 index 8dd2f5b1a..000000000 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m +++ /dev/null @@ -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
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 * _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 * _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 diff --git a/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift b/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift index 946ca8f5c..3294e3457 100644 --- a/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift +++ b/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift @@ -10,7 +10,7 @@ import Foundation enum BlockquoteAttribute: AttributedStringKey { typealias Value = Bool - public static var name = kMXKToolsBlockquoteMarkAttribute + public static var name = "MXBlockquoteAttribute" } extension AttributeScopes { diff --git a/ElementX/Sources/Other/MatrixEntitityRegex.swift b/ElementX/Sources/Other/MatrixEntitityRegex.swift new file mode 100644 index 000000000..f6909e261 --- /dev/null +++ b/ElementX/Sources/Other/MatrixEntitityRegex.swift @@ -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 + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index c0d6623b8..1ef933437 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -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, diff --git a/ElementX/Supporting Files/ElementX-Bridging-Header.h b/ElementX/Supporting Files/ElementX-Bridging-Header.h index daa15bee9..e11d920b1 100644 --- a/ElementX/Supporting Files/ElementX-Bridging-Header.h +++ b/ElementX/Supporting Files/ElementX-Bridging-Header.h @@ -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" diff --git a/ElementXTests/AttributedStringBuilderTests.swift b/ElementXTests/AttributedStringBuilderTests.swift new file mode 100644 index 000000000..0b4ac53ce --- /dev/null +++ b/ElementXTests/AttributedStringBuilderTests.swift @@ -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. + } +}