/* Copyright 2024 New Vector Ltd. Copyright 2016 OpenMarket Ltd SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ #import #import "MatrixKit.h" #import "MXKEventFormatter+Tests.h" @import DTCoreText; @interface MXKEventFormatterTests : XCTestCase { MXKEventFormatter *eventFormatter; MXEvent *anEvent; CGFloat maxHeaderSize; } @end @implementation MXKEventFormatterTests - (void)setUp { [super setUp]; // Create a minimal event formatter // Note: it may not be enough for testing all MXKEventFormatter methods eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:nil]; eventFormatter.treatMatrixUserIdAsLink = YES; eventFormatter.treatMatrixRoomIdAsLink = YES; eventFormatter.treatMatrixRoomAliasAsLink = YES; eventFormatter.treatMatrixEventIdAsLink = YES; anEvent = [[MXEvent alloc] init]; anEvent.roomId = @"aRoomId"; anEvent.eventId = @"anEventId"; anEvent.wireType = kMXEventTypeStringRoomMessage; anEvent.originServerTs = (uint64_t) ([[NSDate date] timeIntervalSince1970] * 1000); anEvent.wireContent = @{ kMXMessageTypeKey: kMXMessageTypeText, kMXMessageBodyKey: @"deded" }; maxHeaderSize = ceil(eventFormatter.defaultTextFont.pointSize * 1.2); } - (void)tearDown { [super tearDown]; } - (void)testRenderHTMLStringWithHeaders { // Given HTML strings with h1/h2/h3 tags NSString *h1HTML = @"

Large Heading

"; NSString *h2HTML = @"

Smaller Heading

"; NSString *h3HTML = @"

Acceptable Heading

"; // When rendering these strings as attributed strings NSAttributedString *h1AttributedString = [eventFormatter renderHTMLString:h1HTML forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; NSAttributedString *h2AttributedString = [eventFormatter renderHTMLString:h2HTML forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; NSAttributedString *h3AttributedString = [eventFormatter renderHTMLString:h3HTML forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; // Then the h1/h2 fonts should be reduced in size to match h3. XCTAssertEqualObjects(h1AttributedString.string, @"Large Heading", @"The text from an H1 tag should be preserved when removing formatting."); XCTAssertEqualObjects(h2AttributedString.string, @"Smaller Heading", @"The text from an H2 tag should be preserved when removing formatting."); XCTAssertEqualObjects(h3AttributedString.string, @"Acceptable Heading", @"The text from an H3 tag should not change."); [h1AttributedString enumerateAttributesInRange:NSMakeRange(0, h1AttributedString.length) options:0 usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { UIFont *font = attributes[NSFontAttributeName]; XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H1 tags should be larger than the default body size."); XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H1 tags shouldn't exceed the max header size."); }]; [h2AttributedString enumerateAttributesInRange:NSMakeRange(0, h2AttributedString.length) options:0 usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { UIFont *font = attributes[NSFontAttributeName]; XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H2 tags should be larger than the default body size."); XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H2 tags shouldn't exceed the max header size."); }]; [h3AttributedString enumerateAttributesInRange:NSMakeRange(0, h3AttributedString.length) options:0 usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { UIFont *font = attributes[NSFontAttributeName]; XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H3 tags should be included and be larger than the default body size."); XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H3 tags shouldn't exceed the max header size."); }]; } - (void)testRenderHTMLStringWithPreCode { NSString *html = @"
1\n2\n3\n4\n
"; NSAttributedString *as = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; NSString *a = as.string; // \R : any newlines NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\R" options:0 error:0]; XCTAssertEqual(3, [regex numberOfMatchesInString:a options:0 range:NSMakeRange(0, a.length)], "renderHTMLString must keep line break in
 and  blocks");

    [as enumerateAttributesInRange:NSMakeRange(0, as.length) options:(0) usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) {

        UIFont *font = attrs[NSFontAttributeName];
        XCTAssertEqualObjects(font.fontName, @"Menlo-Regular", "The font for 
 and  should be monospace");
    }];
}

- (void)testRenderHTMLStringWithLink
{
    // Given an HTML string with a link inside of it.
    NSString *html = @"This text contains a link.";
    
    // When rendering this string as an attributed string.
    NSAttributedString *attributedString = [eventFormatter renderHTMLString:html
                                                                   forEvent:anEvent
                                                              withRoomState:nil
                                                         andLatestRoomState:nil];
    
    // Then the attributed string should contain all of the text,
    XCTAssertEqualObjects(attributedString.string, @"This text contains a link.", @"The text should be preserved when adding a link.");
    
    // and the link should be added as an attachment.
    __block BOOL didFindLink = NO;
    [attributedString enumerateAttribute:NSLinkAttributeName
                                 inRange:NSMakeRange(0, attributedString.length)
                                 options:0
                              usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) {
        if ([value isKindOfClass:NSURL.class])
        {
            NSURL *url = (NSURL *)value;
            XCTAssertEqualObjects(url, [NSURL URLWithString:@"https://www.matrix.org/"], @"href links should be included in the text.");
            didFindLink = YES;
        }
    }];
    
    XCTAssertTrue(didFindLink, @"There should be a link in the attributed string.");
}

- (void)testRenderHTMLStringWithLinkInHeader
{
    // Given HTML strings with links contained within h1/h2 tags.
    NSString *h1HTML = @"

Matrix.org

"; NSString *h3HTML = @"

Matrix.org

"; // When rendering these strings as attributed strings. NSAttributedString *h1AttributedString = [eventFormatter renderHTMLString:h1HTML forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; NSAttributedString *h3AttributedString = [eventFormatter renderHTMLString:h3HTML forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; // Then the attributed string should contain all of the text, XCTAssertEqualObjects(h1AttributedString.string, @"Matrix.org", @"The text from an H1 tag should be preserved when removing formatting."); XCTAssertEqualObjects(h3AttributedString.string, @"Matrix.org", @"The text from an H3 tag should not change."); // and be formatted as a header with the link added as an attachment. __block BOOL didFindH1Link = NO; [h1AttributedString enumerateAttributesInRange:NSMakeRange(0, h1AttributedString.length) options:0 usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { UIFont *font = attributes[NSFontAttributeName]; NSURL *url = attributes[NSLinkAttributeName]; if (font) { XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H1 tags should be larger than the default body size."); XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H1 tags shouldn't exceed the max header size."); } if (url) { XCTAssertEqualObjects(url, [NSURL URLWithString:@"https://www.matrix.org/"], @"href links should be included in the text."); didFindH1Link = YES; } }]; __block BOOL didFindH3Link = NO; [h3AttributedString enumerateAttributesInRange:NSMakeRange(0, h3AttributedString.length) options:0 usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { UIFont *font = attributes[NSFontAttributeName]; NSURL *url = attributes[NSLinkAttributeName]; if (font) { XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H3 tags should be included and be larger than the default."); } if (url) { XCTAssertEqualObjects(url, [NSURL URLWithString:@"https://www.matrix.org/"], @"href links should be included in the text."); didFindH3Link = YES; } }]; XCTAssertTrue(didFindH1Link, @"There should be a link in the sanitised attributed string."); XCTAssertTrue(didFindH3Link, @"There should be a link in the attributed string."); } - (void)testRenderHTMLStringWithIFrame { // Given an HTML string containing an unsupported iframe. NSString *html = @""; // When rendering this string as an attributed string. NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; // Then the attributed string should have the iframe stripped and not include any attachments. BOOL hasAttachment = [attributedString containsAttachmentsInRange:NSMakeRange(0, attributedString.length)]; XCTAssertFalse(hasAttachment, @"iFrame attachments should be removed as they're not included in the allowedHTMLTags array."); } - (void)testRenderHTMLStringWithMXReply { // Given an HTML string representing a matrix reply. NSString *html = @"
In reply to @alice:matrix.org
Original message.
This is a reply."; // When rendering this string as an attributed string. NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; // Then the attributed string should contain all of the text, NSString *plainString = [attributedString.string stringByReplacingOccurrencesOfString:@"\U00002028" withString:@"\n"]; XCTAssertEqualObjects(plainString, @"In reply to @alice:matrix.org\nOriginal message.\nThis is a reply.", @"The reply string should include who the original message was from, what they said, and the reply itself."); // and format the author and original message inside of a quotation block. __block BOOL didTestReplyText = NO; __block BOOL didTestQuoteBlock = NO; [attributedString enumerateAttributesInRange:NSMakeRange(0, attributedString.length) options:0 usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { NSString *substring = [attributedString attributedSubstringFromRange:range].string; if ([substring isEqualToString:@"This is a reply."]) { XCTAssertNil(attributes[DTTextBlocksAttribute], @"The reply text should not appear within a block"); didTestReplyText = YES; } else { XCTAssertNotNil(attributes[DTTextBlocksAttribute], @"The rest of the string should be within a block"); didTestQuoteBlock = YES; } }]; XCTAssertTrue(didTestReplyText && didTestQuoteBlock, @"Both a quote and a reply should be in the attributed string."); } - (void)testRenderHTMLStringWithMXReplyQuotingInvalidMessage { // Given an HTML string representing a matrix reply where the original message has invalid HTML. NSString *html = @"
In reply to @alice:matrix.org

Heading with invalid content

This is a reply."; // When rendering this string as an attributed string. NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; // Then the attributed string should contain all of the text, NSString *plainString = [attributedString.string stringByReplacingOccurrencesOfString:@"\U00002028" withString:@"\n"]; XCTAssertEqualObjects(plainString, @"In reply to @alice:matrix.org\nHeading with invalid content\nThis is a reply.", @"The reply string should include who the original message was from, what they said, and the reply itself."); // and format the author and original message inside of a quotation block. This check // is to catch any incorrectness in the sanitizing where the original message becomes // indented but is missing the block quote mark attribute. __block BOOL didTestReplyText = NO; __block BOOL didTestQuoteBlock = NO; [attributedString enumerateAttributesInRange:NSMakeRange(0, attributedString.length) options:0 usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { NSString *substring = [attributedString attributedSubstringFromRange:range].string; if ([substring isEqualToString:@"This is a reply."]) { XCTAssertNil(attributes[DTTextBlocksAttribute], @"The reply text should not appear within a block"); didTestReplyText = YES; } else { XCTAssertNotNil(attributes[DTTextBlocksAttribute], @"The rest of the string should be within a block"); XCTAssertNotNil(attributes[kMXKToolsBlockquoteMarkAttribute], @"The block should have the blockquote style applied"); didTestQuoteBlock = YES; } }]; XCTAssertTrue(didTestReplyText && didTestQuoteBlock, @"Both a quote and a reply should be in the attributed string."); } - (void)testRenderHTMLStringWithImageHandler { MXWeakify(self); // Given an HTML string that contains an image tag inline. NSURL *imageURL = [NSURL URLWithString:@"https://matrix.org/images/matrix-logo.svg"]; NSString *html = [NSString stringWithFormat:@"Look at this logo: Very nice.", imageURL.absoluteString]; // When rendering this string as an attributed string using an appropriate image handler block. eventFormatter.allowedHTMLTags = [eventFormatter.allowedHTMLTags arrayByAddingObject:@"img"]; eventFormatter.htmlImageHandler = ^NSURL *(NSString *sourceURL, CGFloat width, CGFloat height) { MXStrongifyAndReturnValueIfNil(self, nil); // Replace the image URL with one from the tests bundle NSBundle *bundle = [NSBundle bundleForClass:self.class];; return [bundle URLForResource:@"test" withExtension:@"png"]; }; NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil andLatestRoomState:nil]; // Then the attributed string should contain all of the text, NSString *plainString = [attributedString.string stringByReplacingOccurrencesOfString:@"\U0000fffc" withString:@""]; XCTAssertEqualObjects(plainString, @"Look at this logo: Very nice.", @"The string should include the original text."); // and have the image included as an attachment. __block BOOL hasImageAttachment = NO; [attributedString enumerateAttribute:NSAttachmentAttributeName inRange:NSMakeRange(0, attributedString.length) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) { if ([value image]) { hasImageAttachment = YES; } }]; XCTAssertTrue(hasImageAttachment, @"There should be an attachment that contains the image."); } - (void)testMarkdownFormatting { NSString *html = [eventFormatter htmlStringFromMarkdownString:@"Line One.\nLine Two."]; BOOL hardBreakExists = [html rangeOfString:@"
"].location != NSNotFound; BOOL openParagraphExists = [html rangeOfString:@"

"].location != NSNotFound; BOOL closeParagraphExists = [html rangeOfString:@"

"].location != NSNotFound; // Check for some known error cases XCTAssert(hardBreakExists, "The soft break (\\n) must be converted to a hard break (
)."); XCTAssert(!openParagraphExists && !closeParagraphExists, "The html must not contain any opening or closing paragraph tags."); } #pragma mark - Links - (void)testRoomAliasLink { NSString *s = @"Matrix HQ room is at #matrix:matrix.org."; NSAttributedString *as = [eventFormatter renderString:s forEvent:anEvent]; NSRange linkRange = [s rangeOfString:@"#matrix:matrix.org"]; __block NSUInteger ranges = 0; __block BOOL linkCreated = NO; [as enumerateAttributesInRange:NSMakeRange(0, as.length) options:(0) usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { ranges++; if (NSEqualRanges(linkRange, range)) { linkCreated = (attrs[NSLinkAttributeName] != nil); } }]; XCTAssertEqual(ranges, 3, @"A sub-component must have been found"); XCTAssert(linkCreated, @"Link not created as expected: %@", as); } - (void)testLinkWithRoomAliasLink { NSString *s = @"Matrix HQ room is at https://matrix.to/#/room/#matrix:matrix.org."; NSAttributedString *as = [eventFormatter renderString:s forEvent:anEvent]; __block BOOL hasLink = false; [as enumerateAttributesInRange:NSMakeRange(0, as.length) options:(0) usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { if (attrs[NSLinkAttributeName]) { hasLink = true; *stop = true; } }]; XCTAssertEqual(hasLink, true, @"There should be a link, so that a Pill can be rendered for this permalink."); } #pragma mark - Event sender/target info - (void)testUserDisplayNameFromEventContent { MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"displayname\":\"bob\",\"membership\":\"invite\"},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.member\",\"unsigned\":{}}"]; XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:nil], @"bob"); XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:@"invite"], @"bob"); XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:@"join"], nil); } - (void)testUserDisplayNameFromNonMembershipEventContent { MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"ciphertext\":\"foo\",\"sender_key\":\"bar\",\"device_id\":\"foobar\",\"algorithm\":\"m.megolm.v1.aes-sha2\"}},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.encrypted\",\"unsigned\":{}}"]; XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:nil], nil); XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:@"join"], nil); } - (void)testUserAvatarUrlFromEventContent { MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"displayname\":\"bob\",\"avatar_url\":\"mxc://foo.bar\",\"membership\":\"join\"},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.member\",\"unsigned\":{}}"]; XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:nil], @"mxc://foo.bar"); XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"invite"], nil); XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"], @"mxc://foo.bar"); } - (void)testUserAvatarUrlFromEventWithNonMXCAvatarUrlContent { MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"displayname\":\"bob\",\"avatar_url\":\"http://foo.bar\",\"membership\":\"join\"},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.member\",\"unsigned\":{}}"]; XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:nil], nil); XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"invite"], nil); XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"], nil); } - (void)testUserAvatarUrlFromNonMembershipEventContent { MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"ciphertext\":\"foo\",\"sender_key\":\"bar\",\"device_id\":\"foobar\",\"algorithm\":\"m.megolm.v1.aes-sha2\"}},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.encrypted\",\"unsigned\":{}}"]; XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:nil], nil); XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"], nil); } - (MXEvent *)eventFromJSON:(NSString *)json { NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; return [MXEvent modelFromJSON:dict]; } @end