Initial timeline HTML support.

This commit is contained in:
Stefan Ceriu
2022-03-24 14:47:55 +02:00
parent d3588cf6f1
commit cf42d61d27
20 changed files with 683 additions and 51 deletions
+2 -1
View File
@@ -116,7 +116,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
let memberDetailsProvider = MemberDetailsProvider(roomProxy: roomProxy)
let timelineItemFactory = RoomTimelineItemFactory(mediaProvider: userSession.mediaProvider,
memberDetailsProvider: memberDetailsProvider)
memberDetailsProvider: memberDetailsProvider,
attributedStringBuilder: AttributedStringBuilder())
let timelineController = RoomTimelineController(timelineProvider: RoomTimelineProvider(roomProxy: roomProxy),
timelineItemFactory: timelineItemFactory,
@@ -0,0 +1,85 @@
//
// AttributedStringBuilder.swift
// ElementX
//
// Created by Stefan Ceriu on 22/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
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;
}
h1,h2,h3 {
font-size: 1.2em;
}
"""
}
// Do not use the default HTML renderer of NSAttributedString because this method
// runs on the UI thread which we want to avoid because renderHTMLString is called
// most of the time from a background thread.
// Use DTCoreText HTML renderer instead.
// Using DTCoreText, which renders static string, helps to avoid code injection attacks
// that could happen with the default HTML renderer of NSAttributedString which is a
// webview.
func fromHTML(_ htmlString: String?) -> AttributedString? {
guard let htmlString = htmlString,
let data = htmlString.data(using: .utf8) else {
return nil
}
let defaultFont = UIFont.preferredFont(forTextStyle: .body)
let defaultColor = UIColor.black
let parsingOptions: [String: Any] = [
DTUseiOS6Attributes: true,
DTDefaultFontFamily: defaultFont.familyName,
DTDefaultFontName: defaultFont.fontName,
DTDefaultFontSize: defaultFont.pointSize,
DTDefaultTextColor: defaultColor,
DTDefaultLinkDecoration: false,
DTDefaultStyleSheet: DTCSSStylesheet(styleBlock: self.defaultCSS) as Any
]
guard let builder = DTHTMLAttributedStringBuilder(html: data, options: parsingOptions, documentAttributes: nil) else {
return nil
}
builder.willFlushCallback = { element in
element?.sanitize(font: defaultFont)
}
guard var nsAttributedString = builder.generatedAttributedString() else {
return nil
}
nsAttributedString = AttributedStringBuilderUtils.removeDTCoreTextArtifacts(nsAttributedString)
nsAttributedString = AttributedStringBuilderUtils.removeMarkedBlockquotesArtifacts(nsAttributedString)
return try? AttributedString(nsAttributedString, including: \.elementX)
}
func blockquoteCoalescedComponentsFrom(_ attributedString: AttributedString?) -> [AttributedStringBuilderComponent]? {
guard let attributedString = attributedString else {
return nil
}
return attributedString.runs[\.blockquote].map { (value, range) in
AttributedStringBuilderComponent(attributedString: AttributedString(attributedString[range]),
isBlockquote: value != nil)
}
}
}
@@ -0,0 +1,20 @@
//
// AttributedStringBuilderProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 24/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
struct AttributedStringBuilderComponent: Hashable {
let attributedString: AttributedString
let isBlockquote: Bool
}
protocol AttributedStringBuilderProtocol {
func fromHTML(_ htmlString: String?) -> AttributedString?
func blockquoteCoalescedComponentsFrom(_ attributedString: AttributedString?) -> [AttributedStringBuilderComponent]?
}
@@ -0,0 +1,27 @@
//
// 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
+ (NSAttributedString *)removeDTCoreTextArtifacts:(NSAttributedString *)attributedString;
+ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString;
+ (NSString*)cssToMarkBlockquotes;
+ (void)enumerateMarkedBlockquotesInAttributedString:(NSAttributedString*)attributedString usingBlock:(void (^)(NSRange range, BOOL *stop))block;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,200 @@
//
// 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";
@implementation AttributedStringBuilderUtils
+ (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;
}
+ (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];
// 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;
}
+ (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;
}
+ (void)enumerateMarkedBlockquotesInAttributedString:(NSAttributedString*)attributedString usingBlock:(void (^)(NSRange range, BOOL *stop))block
{
[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);
}
}];
}
@end
@@ -0,0 +1,71 @@
//
// DTHTMLElement+AttributedStringBuilder.swift
// ElementX
//
// Created by Stefan Ceriu on 24/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import DTCoreText
public extension DTHTMLElement {
/// Sanitize the element using the given parameters.
/// - Parameters:
/// - allowedHTMLTags: An array of tags that are allowed. All other tags will be removed.
/// - font: The default font to use when resetting the content of any unsupported tags.
/// - imageHandler: An optional image handler to be run on `img` tags (if allowed) to update the `src` attribute.
@objc func sanitize(font: UIFont) {
if let name = name, !Self.allowedHTMLTags.contains(name) {
// This is an unsupported tag.
// Remove any attachments to fix rendering.
textAttachment = nil
// If the element has plain text content show that,
// otherwise prevent the tag from displaying.
if let stringContent = attributedString()?.string,
!stringContent.isEmpty,
let element = DTTextHTMLElement(name: nil, attributes: nil) {
element.setText(stringContent)
removeAllChildNodes()
addChildNode(element)
if let parent = parent() {
element.inheritAttributes(from: parent)
} else {
fontDescriptor = DTCoreTextFontDescriptor()
fontDescriptor.fontFamily = font.familyName
fontDescriptor.fontName = font.fontName
fontDescriptor.pointSize = font.pointSize
paragraphStyle = DTCoreTextParagraphStyle.default()
element.inheritAttributes(from: self)
}
element.interpretAttributes()
} else if let parent = parent() {
parent.removeChildNode(self)
} else {
didOutput = true
}
} else {
// This element is a supported tag, but it may contain children that aren't,
// so santize all child nodes to ensure correct tags.
if let childNodes = childNodes as? [DTHTMLElement] {
childNodes.forEach { $0.sanitize(font: font) }
}
}
}
private static var allowedHTMLTags = {
["font", // custom to matrix for IRC-style font coloring
"del", // for markdown
"body", // added internally by DTCoreText
"mx-reply",
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p", "a", "ul", "ol",
"nl", "li", "b", "i", "u", "strong", "em", "strike", "code", "hr", "br", "div",
"table", "thead", "caption", "tbody", "tr", "th", "td", "pre"]
}()
}
@@ -0,0 +1,31 @@
//
// ElementXAttributeScope.swift
// ElementX
//
// Created by Stefan Ceriu on 23/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
enum BlockquoteAttribute: AttributedStringKey {
typealias Value = Bool
public static var name = kMXKToolsBlockquoteMarkAttribute
}
extension AttributeScopes {
struct ElementXAttributes: AttributeScope {
let blockquote: BlockquoteAttribute
let swiftUI: SwiftUIAttributes
let uiKit: UIKitAttributes
}
var elementX: ElementXAttributes.Type { ElementXAttributes.self }
}
extension AttributeDynamicLookup {
subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.ElementXAttributes, T>) -> T {
return self[T.self]
}
}
@@ -0,0 +1,19 @@
//
// UIFont+AttributedStringBuilder.h
// ElementX
//
// Created by Stefan Ceriu on 23/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
@import UIKit;
NS_ASSUME_NONNULL_BEGIN
@interface UIFont(DTCoreTextFix)
// Fix DTCoreText iOS 13 issue (https://github.com/Cocoanetics/DTCoreText/issues/1168)
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,73 @@
//
// UIFont+AttributedStringBuilder.h
// ElementX
//
// Created by Stefan Ceriu on 23/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
#import "UIFont+AttributedStringBuilder.h"
@import UIKit;
@import CoreText;
@import ObjectiveC;
#pragma mark - UIFont DTCoreText fix
@interface UIFont (vc_DTCoreTextFix)
+ (UIFont *)vc_fixedFontWithCTFont:(CTFontRef)ctFont;
@end
@implementation UIFont (vc_DTCoreTextFix)
+ (UIFont *)vc_fixedFontWithCTFont:(CTFontRef)ctFont {
NSString *fontName = (__bridge_transfer NSString *)CTFontCopyName(ctFont, kCTFontPostScriptNameKey);
CGFloat fontSize = CTFontGetSize(ctFont);
UIFont *font = [UIFont fontWithName:fontName size:fontSize];
// On iOS 13+ "TimesNewRomanPSMT" will be used instead of "SFUI"
// In case of "Times New Roman" fallback, use system font and reuse UIFontDescriptorSymbolicTraits.
if ([font.familyName.lowercaseString containsString:@"times"])
{
UIFontDescriptorSymbolicTraits symbolicTraits = (UIFontDescriptorSymbolicTraits)CTFontGetSymbolicTraits(ctFont);
UIFontDescriptor *systemFontDescriptor = [UIFont systemFontOfSize:fontSize].fontDescriptor;
UIFontDescriptor *finalFontDescriptor = [systemFontDescriptor fontDescriptorWithSymbolicTraits:symbolicTraits];
font = [UIFont fontWithDescriptor:finalFontDescriptor size:fontSize];
}
return font;
}
@end
#pragma mark - Implementation
@implementation UIFont(DTCoreTextFix)
// DTCoreText iOS 13 fix. See issue and comment here: https://github.com/Cocoanetics/DTCoreText/issues/1168#issuecomment-583541514
// Also see https://github.com/Cocoanetics/DTCoreText/pull/1245 for a possible future solution
+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class originalClass = object_getClass([UIFont class]);
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
SEL originalSelector = @selector(fontWithCTFont:); // DTCoreText method we're overriding
SEL ourSelector = @selector(vc_fixedFontWithCTFont:); // Use custom implementation
#pragma clang diagnostic pop
Method originalMethod = class_getClassMethod(originalClass, originalSelector);
Method swizzledMethod = class_getClassMethod(originalClass, ourSelector);
method_exchangeImplementations(originalMethod, swizzledMethod);
});
}
@end
@@ -14,10 +14,10 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
var timelineItems: [RoomTimelineItemProtocol] = [SeparatorRoomTimelineItem(id: UUID().uuidString, text: "Yesterday"),
TextRoomTimelineItem(id: UUID().uuidString, body: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true, senderId: "Alice"),
TextRoomTimelineItem(id: UUID().uuidString, body: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false, senderId: "Alice"),
TextRoomTimelineItem(id: UUID().uuidString, text: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true, senderId: "Alice"),
TextRoomTimelineItem(id: UUID().uuidString, text: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false, senderId: "Alice"),
SeparatorRoomTimelineItem(id: UUID().uuidString, text: "Today"),
TextRoomTimelineItem(id: UUID().uuidString, body: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true, senderId: "Bob")]
TextRoomTimelineItem(id: UUID().uuidString, text: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true, senderId: "Bob")]
func paginateBackwards(_ count: UInt, callback: ((Result<Void, RoomTimelineControllerError>) -> Void)) {
callbacks.send(.updatedTimelineItems)
@@ -10,7 +10,7 @@ import Foundation
import UIKit
protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol {
var body: String { get }
var text: String { get }
var timestamp: String { get }
var shouldShowSenderDetails: Bool { get }
@@ -11,7 +11,7 @@ import UIKit
struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
let id: String
let body: String
let text: String
let timestamp: String
let shouldShowSenderDetails: Bool
@@ -11,8 +11,8 @@ import UIKit
struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
let id: String
let body: String
var htmlBody: String?
let text: String
var attributedComponents: [AttributedStringBuilderComponent]?
let timestamp: String
let shouldShowSenderDetails: Bool
@@ -12,11 +12,14 @@ import UIKit
struct RoomTimelineItemFactory {
private let mediaProvider: MediaProviderProtocol
private let memberDetailsProvider: MemberDetailsProviderProtocol
private let attributedStringBuilder: AttributedStringBuilderProtocol
init(mediaProvider: MediaProviderProtocol,
memberDetailsProvider: MemberDetailsProviderProtocol) {
memberDetailsProvider: MemberDetailsProviderProtocol,
attributedStringBuilder: AttributedStringBuilderProtocol) {
self.mediaProvider = mediaProvider
self.memberDetailsProvider = memberDetailsProvider
self.attributedStringBuilder = attributedStringBuilder
}
func buildTimelineItemFor(_ roomMessage: RoomMessageProtocol, showSenderDetails: Bool) -> RoomTimelineItemProtocol {
@@ -27,9 +30,12 @@ struct RoomTimelineItemFactory {
switch roomMessage {
case let message as TextRoomMessage:
let attributedText = attributedStringBuilder.fromHTML(message.htmlBody)
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
return TextRoomTimelineItem(id: message.id,
body: message.body,
htmlBody: message.htmlBody,
text: message.body,
attributedComponents: attributedComponents,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
senderId: message.sender,
@@ -37,7 +43,7 @@ struct RoomTimelineItemFactory {
senderAvatar: avatarImage)
case let message as ImageRoomMessage:
return ImageRoomTimelineItem(id: message.id,
body: message.body,
text: message.body,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
senderId: message.sender,
@@ -0,0 +1,33 @@
//
// FormattedBodyText.swift
// ElementX
//
// Created by Stefan Ceriu on 24/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct FormattedBodyText: View {
let attributedComponents: [AttributedStringBuilderComponent]
var body: some View {
VStack(alignment: .leading, spacing: 0.0) {
ForEach(attributedComponents, id: \.self) { component in
if component.isBlockquote {
HStack(spacing: 4.0) {
Rectangle()
.foregroundColor(Color.red)
.frame(width: 4.0)
Text(component.attributedString)
.fixedSize(horizontal: false, vertical: true)
}
} else {
Text(component.attributedString)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
@@ -16,7 +16,7 @@ struct ImageRoomTimelineView: View {
if let image = timelineItem.image {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
Text(timelineItem.body)
Text(timelineItem.text)
Image(uiImage: image)
.resizable()
.scaledToFit()
@@ -24,7 +24,7 @@ struct ImageRoomTimelineView: View {
} else {
VStack(alignment: .center) {
HStack {
Text(timelineItem.body)
Text(timelineItem.text)
Spacer()
}
ProgressView("Loading")
@@ -37,7 +37,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
VStack {
let timelineItem = ImageRoomTimelineItem(id: UUID().uuidString,
body: "Some image",
text: "Some image",
timestamp: "Now",
shouldShowSenderDetails: false,
senderId: "Bob",
@@ -46,7 +46,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: timelineItem)
let timelineItem = ImageRoomTimelineItem(id: UUID().uuidString,
body: "Some other image",
text: "Some other image",
timestamp: "Now",
shouldShowSenderDetails: false,
senderId: "Bob",
@@ -15,52 +15,28 @@ struct TextRoomTimelineView: View {
var body: some View {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
if let htmlString = buildHtmlString() {
Text(AttributedString(htmlString))
.fixedSize(horizontal: false, vertical: true)
if let components = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: components)
} else {
if let attributedString = try? AttributedString(markdown: timelineItem.body) {
Text(attributedString)
.fixedSize(horizontal: false, vertical: true)
} else {
Text(timelineItem.body)
.fixedSize(horizontal: false, vertical: true)
}
Text(timelineItem.text)
}
}
.id(timelineItem.id)
}
private func buildHtmlString() -> NSAttributedString? {
guard let formattedText = timelineItem.htmlBody,
let encodedData = formattedText.data(using: String.Encoding.utf8) else {
return nil
}
do {
return try NSAttributedString(data: encodedData, options: [
NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html,
NSAttributedString.DocumentReadingOptionKey.characterEncoding: NSNumber(value: String.Encoding.utf8.rawValue)
], documentAttributes: nil)
} catch {
return nil
}
}
}
struct TextRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 20.0) {
let timelineItem = TextRoomTimelineItem(id: UUID().uuidString,
body: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
shouldShowSenderDetails: true,
senderId: "Bob")
TextRoomTimelineView(timelineItem: timelineItem)
let timelineItem = TextRoomTimelineItem(id: UUID().uuidString,
body: "Some other text",
text: "Some other text",
timestamp: "Later",
shouldShowSenderDetails: true,
senderId: "Anne")
@@ -0,0 +1,5 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#import "AttributedStringBuilderUtils.h"