diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index efc6c6451..b62c065a2 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -49,7 +49,6 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { DTDefaultFontName: defaultFont.fontName, DTDefaultFontSize: defaultFont.pointSize, DTDefaultTextColor: defaultColor, - DTDefaultLinkDecoration: false, DTDefaultStyleSheet: DTCSSStylesheet(styleBlock: self.defaultCSS) as Any ] @@ -69,6 +68,8 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { nsAttributedString = AttributedStringBuilderUtils.removeMarkedBlockquotesArtifacts(nsAttributedString) + nsAttributedString = AttributedStringBuilderUtils.createLinks(nsAttributedString) + return try? AttributedString(nsAttributedString, including: \.elementX) } diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h index eb2cb6d24..84c3970b7 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h @@ -14,13 +14,13 @@ FOUNDATION_EXPORT NSString *const kMXKToolsBlockquoteMarkAttribute; @interface AttributedStringBuilderUtils : NSObject ++ (NSString*)cssToMarkBlockquotes; + + (NSAttributedString *)removeDTCoreTextArtifacts:(NSAttributedString *)attributedString; + (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString; -+ (NSString*)cssToMarkBlockquotes; - -+ (void)enumerateMarkedBlockquotesInAttributedString:(NSAttributedString*)attributedString usingBlock:(void (^)(NSRange range, BOOL *stop))block; ++ (NSAttributedString*)createLinks:(NSAttributedString*)attributedString; @end diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m index fc1174f04..85f2dc194 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m @@ -15,8 +15,57 @@ // 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?://.*)\\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]; @@ -91,11 +140,6 @@ NSString *const kMXKToolsBlockquoteMarkAttribute = @"kMXKToolsBlockquoteMarkAttr return mutableAttributedString; } -+ (NSString*)cssToMarkBlockquotes -{ - return [NSString stringWithFormat:@"blockquote {background: #%lX; display: block;}", (unsigned long)[[self class] rgbValueWithColor:kMXKToolsBlockquoteMarkColor]]; -} - + (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString { NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString]; @@ -172,29 +216,125 @@ NSString *const kMXKToolsBlockquoteMarkAttribute = @"kMXKToolsBlockquoteMarkAttr return mutableAttributedString; } -+ (NSUInteger)rgbValueWithColor:(UIColor*)color ++ (NSAttributedString*)createLinks:(NSAttributedString*)attributedString { - CGFloat red, green, blue, alpha; + if (!attributedString) + { + return nil; + } - [color getRed:&red green:&green blue:&blue alpha:&alpha]; + NSMutableAttributedString *postRenderAttributedString; - NSUInteger rgbValue = ((int)(red * 255) << 16) + ((int)(green * 255) << 8) + (blue * 255); + [[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 rgbValue; + return postRenderAttributedString ? postRenderAttributedString : attributedString; } -+ (void)enumerateMarkedBlockquotesInAttributedString:(NSAttributedString*)attributedString usingBlock:(void (^)(NSRange range, BOOL *stop))block ++ (void)createHTTPLinksInAttributedString:(NSAttributedString*)attributedString + withWorkingAttributedString:(NSMutableAttributedString* __autoreleasing *)mutableAttributedString { - [attributedString enumerateAttribute:kMXKToolsBlockquoteMarkAttribute - inRange:NSMakeRange(0, attributedString.length) - options:0 - usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) - { - if ([value isKindOfClass:NSNumber.class] && ((NSNumber*)value).boolValue) - { - block(range, stop); - } - }]; + // 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/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index bbaea01d8..f57866f57 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -24,6 +24,7 @@ enum RoomScreenViewAction { case loadPreviousPage case itemAppeared(id: String) case itemDisappeared(id: String) + case linkClicked(url: URL) } struct RoomScreenViewState: BindableState { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 62ba1ff44..80938f522 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -72,6 +72,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol timelineController.processItemAppearance(id) case .itemDisappeared(let id): timelineController.processItemDisappearance(id) + case .linkClicked(let url): + MXLog.warning("Link clicked: \(url)") } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 7778dce61..fff6fb3e8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -49,6 +49,10 @@ struct RoomScreen: View { .onDisappear { context.send(viewAction: .itemDisappeared(id: timelineItem.id)) } + .environment(\.openURL, OpenURLAction { url in + context.send(viewAction: .linkClicked(url: url)) + return .systemAction + }) } } .listStyle(.plain) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index cc87a3050..c0d6623b8 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -30,7 +30,7 @@ struct RoomTimelineItemFactory { switch roomMessage { case let message as TextRoomMessage: - let attributedText = attributedStringBuilder.fromHTML(message.htmlBody) + let attributedText = attributedStringBuilder.fromHTML(message.htmlBody ?? message.body) let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText) return TextRoomTimelineItem(id: message.id, diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Views/TextRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Views/TextRoomTimelineView.swift index 073e0e896..a9dc808e5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Views/TextRoomTimelineView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Views/TextRoomTimelineView.swift @@ -15,8 +15,8 @@ struct TextRoomTimelineView: View { var body: some View { VStack(alignment: .leading) { EventBasedTimelineView(timelineItem: timelineItem) - if let components = timelineItem.attributedComponents { - FormattedBodyText(attributedComponents: components) + if let attributedComponents = timelineItem.attributedComponents { + FormattedBodyText(attributedComponents: attributedComponents) } else { Text(timelineItem.text) }