mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Added link detection in plain text (not available ootb in SwiftUI) and support for user, room, event and group identifiers + room aliases.
This commit is contained in:
@@ -49,7 +49,6 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
DTDefaultFontName: defaultFont.fontName,
|
DTDefaultFontName: defaultFont.fontName,
|
||||||
DTDefaultFontSize: defaultFont.pointSize,
|
DTDefaultFontSize: defaultFont.pointSize,
|
||||||
DTDefaultTextColor: defaultColor,
|
DTDefaultTextColor: defaultColor,
|
||||||
DTDefaultLinkDecoration: false,
|
|
||||||
DTDefaultStyleSheet: DTCSSStylesheet(styleBlock: self.defaultCSS) as Any
|
DTDefaultStyleSheet: DTCSSStylesheet(styleBlock: self.defaultCSS) as Any
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -69,6 +68,8 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
|
|
||||||
nsAttributedString = AttributedStringBuilderUtils.removeMarkedBlockquotesArtifacts(nsAttributedString)
|
nsAttributedString = AttributedStringBuilderUtils.removeMarkedBlockquotesArtifacts(nsAttributedString)
|
||||||
|
|
||||||
|
nsAttributedString = AttributedStringBuilderUtils.createLinks(nsAttributedString)
|
||||||
|
|
||||||
return try? AttributedString(nsAttributedString, including: \.elementX)
|
return try? AttributedString(nsAttributedString, including: \.elementX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ FOUNDATION_EXPORT NSString *const kMXKToolsBlockquoteMarkAttribute;
|
|||||||
|
|
||||||
@interface AttributedStringBuilderUtils : NSObject
|
@interface AttributedStringBuilderUtils : NSObject
|
||||||
|
|
||||||
|
+ (NSString*)cssToMarkBlockquotes;
|
||||||
|
|
||||||
+ (NSAttributedString *)removeDTCoreTextArtifacts:(NSAttributedString *)attributedString;
|
+ (NSAttributedString *)removeDTCoreTextArtifacts:(NSAttributedString *)attributedString;
|
||||||
|
|
||||||
+ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString;
|
+ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString;
|
||||||
|
|
||||||
+ (NSString*)cssToMarkBlockquotes;
|
+ (NSAttributedString*)createLinks:(NSAttributedString*)attributedString;
|
||||||
|
|
||||||
+ (void)enumerateMarkedBlockquotesInAttributedString:(NSAttributedString*)attributedString usingBlock:(void (^)(NSRange range, BOOL *stop))block;
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,57 @@
|
|||||||
// Attribute in an NSAttributeString that marks a blockquote block that was in the original HTML string.
|
// Attribute in an NSAttributeString that marks a blockquote block that was in the original HTML string.
|
||||||
NSString *const kMXKToolsBlockquoteMarkAttribute = @"kMXKToolsBlockquoteMarkAttribute";
|
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
|
@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
|
+ (NSAttributedString *)removeDTCoreTextArtifacts:(NSAttributedString *)attributedString
|
||||||
{
|
{
|
||||||
NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString];
|
NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString];
|
||||||
@@ -91,11 +140,6 @@ NSString *const kMXKToolsBlockquoteMarkAttribute = @"kMXKToolsBlockquoteMarkAttr
|
|||||||
return mutableAttributedString;
|
return mutableAttributedString;
|
||||||
}
|
}
|
||||||
|
|
||||||
+ (NSString*)cssToMarkBlockquotes
|
|
||||||
{
|
|
||||||
return [NSString stringWithFormat:@"blockquote {background: #%lX; display: block;}", (unsigned long)[[self class] rgbValueWithColor:kMXKToolsBlockquoteMarkColor]];
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString
|
+ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString
|
||||||
{
|
{
|
||||||
NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString];
|
NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString];
|
||||||
@@ -172,29 +216,125 @@ NSString *const kMXKToolsBlockquoteMarkAttribute = @"kMXKToolsBlockquoteMarkAttr
|
|||||||
return mutableAttributedString;
|
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
|
// Enumerate each string matching the regex
|
||||||
inRange:NSMakeRange(0, attributedString.length)
|
[httpLinksRegex enumerateMatchesInString:attributedString.string options:0 range:NSMakeRange(0, attributedString.length) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop) {
|
||||||
options:0
|
|
||||||
usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop)
|
// Do not create a link if there is already one on the found match
|
||||||
{
|
__block BOOL hasAlreadyLink = NO;
|
||||||
if ([value isKindOfClass:NSNumber.class] && ((NSNumber*)value).boolValue)
|
[attributedString enumerateAttributesInRange:match.range options:0 usingBlock:^(NSDictionary<NSString *,id> * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) {
|
||||||
{
|
|
||||||
block(range, 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
|
@end
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ enum RoomScreenViewAction {
|
|||||||
case loadPreviousPage
|
case loadPreviousPage
|
||||||
case itemAppeared(id: String)
|
case itemAppeared(id: String)
|
||||||
case itemDisappeared(id: String)
|
case itemDisappeared(id: String)
|
||||||
|
case linkClicked(url: URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RoomScreenViewState: BindableState {
|
struct RoomScreenViewState: BindableState {
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
timelineController.processItemAppearance(id)
|
timelineController.processItemAppearance(id)
|
||||||
case .itemDisappeared(let id):
|
case .itemDisappeared(let id):
|
||||||
timelineController.processItemDisappearance(id)
|
timelineController.processItemDisappearance(id)
|
||||||
|
case .linkClicked(let url):
|
||||||
|
MXLog.warning("Link clicked: \(url)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ struct RoomScreen: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
|
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
|
||||||
}
|
}
|
||||||
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
|
context.send(viewAction: .linkClicked(url: url))
|
||||||
|
return .systemAction
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct RoomTimelineItemFactory {
|
|||||||
|
|
||||||
switch roomMessage {
|
switch roomMessage {
|
||||||
case let message as TextRoomMessage:
|
case let message as TextRoomMessage:
|
||||||
let attributedText = attributedStringBuilder.fromHTML(message.htmlBody)
|
let attributedText = attributedStringBuilder.fromHTML(message.htmlBody ?? message.body)
|
||||||
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
|
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
|
||||||
|
|
||||||
return TextRoomTimelineItem(id: message.id,
|
return TextRoomTimelineItem(id: message.id,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ struct TextRoomTimelineView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
EventBasedTimelineView(timelineItem: timelineItem)
|
EventBasedTimelineView(timelineItem: timelineItem)
|
||||||
if let components = timelineItem.attributedComponents {
|
if let attributedComponents = timelineItem.attributedComponents {
|
||||||
FormattedBodyText(attributedComponents: components)
|
FormattedBodyText(attributedComponents: attributedComponents)
|
||||||
} else {
|
} else {
|
||||||
Text(timelineItem.text)
|
Text(timelineItem.text)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user