From cf42d61d279ea4311331414a32a9388db7c95328 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 24 Mar 2022 14:47:55 +0200 Subject: [PATCH] Initial timeline HTML support. --- ElementX.xcodeproj/project.pbxproj | 79 ++++++- .../xcshareddata/swiftpm/Package.resolved | 18 ++ ElementX/Sources/AppCoordinator.swift | 3 +- .../HTMLParsing/AttributedStringBuilder.swift | 85 ++++++++ .../AttributedStringBuilderProtocol.swift | 20 ++ .../AttributedStringBuilderUtils.h | 27 +++ .../AttributedStringBuilderUtils.m | 200 ++++++++++++++++++ ...THTMLElement+AttributedStringBuilder.swift | 71 +++++++ .../HTMLParsing/ElementXAttributeScope.swift | 31 +++ .../UIFont+AttributedStringBuilder.h | 19 ++ .../UIFont+AttributedStringBuilder.m | 73 +++++++ .../Timeline/MockRoomTimelineController.swift | 6 +- .../EventBasedTimelineItemProtocol.swift | 2 +- .../Items/ImageRoomTimelineItem.swift | 2 +- .../Items/TextRoomTimelineItem.swift | 4 +- .../RoomTimelineItemFactory.swift | 14 +- .../Views/FormattedBodyText.swift | 33 +++ .../Views/ImageRoomTimelineView.swift | 8 +- .../Views/TextRoomTimelineView.swift | 34 +-- .../ElementX-Bridging-Header.h | 5 + 20 files changed, 683 insertions(+), 51 deletions(-) create mode 100644 ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift create mode 100644 ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift create mode 100644 ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h create mode 100644 ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m create mode 100644 ElementX/Sources/Other/HTMLParsing/DTHTMLElement+AttributedStringBuilder.swift create mode 100644 ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift create mode 100644 ElementX/Sources/Other/HTMLParsing/UIFont+AttributedStringBuilder.h create mode 100644 ElementX/Sources/Other/HTMLParsing/UIFont+AttributedStringBuilder.m create mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/Views/FormattedBodyText.swift create mode 100644 ElementX/Supporting Files/ElementX-Bridging-Header.h diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 1b947d36f..e465d36a1 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -22,6 +22,10 @@ 1863A40627BA6DFC00B52E4D /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = 1863A40527BA6DFC00B52E4D /* SwiftyBeaver */; }; 18ADC7D527E4B20300A8C953 /* PlaceholderAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18ADC7D427E4B20300A8C953 /* PlaceholderAvatarImage.swift */; }; 18ADC7D827E4B63C00A8C953 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 18ADC7D727E4B63C00A8C953 /* MatrixRustSDK */; }; + 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 */; }; @@ -29,6 +33,10 @@ 18C5745227E1D88600D70937 /* ImageRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5745127E1D88600D70937 /* ImageRoomMessage.swift */; }; 18C5745427E1D88E00D70937 /* TextRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5745327E1D88E00D70937 /* TextRoomMessage.swift */; }; 18C5745627E1DCA800D70937 /* RoomMessageFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5745527E1DCA800D70937 /* RoomMessageFactory.swift */; }; + 18DDB72127EB9D57000F1ABF /* ElementXAttributeScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DDB72027EB9D57000F1ABF /* ElementXAttributeScope.swift */; }; + 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 */; }; 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 */; }; @@ -137,6 +145,12 @@ 1850256827B6A135002E6B18 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 1850256A27B6A135002E6B18 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 18ADC7D427E4B20300A8C953 /* PlaceholderAvatarImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaceholderAvatarImage.swift; sourceTree = ""; }; + 18ADC7F927EB02D900A8C953 /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = ""; }; + 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 = ""; }; @@ -144,6 +158,11 @@ 18C5745127E1D88600D70937 /* ImageRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomMessage.swift; sourceTree = ""; }; 18C5745327E1D88E00D70937 /* TextRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomMessage.swift; sourceTree = ""; }; 18C5745527E1DCA800D70937 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = ""; }; + 18DDB72027EB9D57000F1ABF /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = ""; }; + 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 = ""; }; + 18DDB72827EC9F49000F1ABF /* matrix-rust-components-swift */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "matrix-rust-components-swift"; path = "../matrix-rust-components-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 = ""; }; @@ -230,6 +249,7 @@ 1863A3FC27BA5A9100B52E4D /* KeychainAccess in Frameworks */, 18ADC7D827E4B63C00A8C953 /* MatrixRustSDK in Frameworks */, 184B31DF27D898960075A669 /* Introspect in Frameworks */, + 18ADC7FE27EB033400A8C953 /* DTCoreText in Frameworks */, 1863A40627BA6DFC00B52E4D /* SwiftyBeaver in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -254,6 +274,7 @@ 1850251B27B6918C002E6B18 = { isa = PBXGroup; children = ( + 18DDB72827EC9F49000F1ABF /* matrix-rust-components-swift */, 1850252627B6918C002E6B18 /* ElementX */, 1850253D27B6918D002E6B18 /* ElementXTests */, 1850254727B6918D002E6B18 /* ElementXUITests */, @@ -313,14 +334,30 @@ 1850256627B6A135002E6B18 /* Supporting Files */ = { isa = PBXGroup; children = ( - 1850256727B6A135002E6B18 /* ElementX.entitlements */, 1850256827B6A135002E6B18 /* Assets.xcassets */, - 1850256927B6A135002E6B18 /* LaunchScreen.storyboard */, + 18ADC80727EB1EE200A8C953 /* ElementX-Bridging-Header.h */, + 1850256727B6A135002E6B18 /* ElementX.entitlements */, 18FE279627C7B85300016375 /* Info.plist */, + 1850256927B6A135002E6B18 /* LaunchScreen.storyboard */, ); path = "Supporting Files"; sourceTree = ""; }; + 18ADC7F827EB02D900A8C953 /* HTMLParsing */ = { + isa = PBXGroup; + children = ( + 18DDB72627EC78B8000F1ABF /* AttributedStringBuilderProtocol.swift */, + 18ADC7F927EB02D900A8C953 /* AttributedStringBuilder.swift */, + 18ADC80527EB1ED100A8C953 /* UIFont+AttributedStringBuilder.h */, + 18ADC80427EB1ED100A8C953 /* UIFont+AttributedStringBuilder.m */, + 18ADC80827EB1F8B00A8C953 /* AttributedStringBuilderUtils.h */, + 18ADC80927EB1F8B00A8C953 /* AttributedStringBuilderUtils.m */, + 18DDB72027EB9D57000F1ABF /* ElementXAttributeScope.swift */, + 18DDB72227EC7702000F1ABF /* DTHTMLElement+AttributedStringBuilder.swift */, + ); + path = HTMLParsing; + sourceTree = ""; + }; 18C5744427E11F1900D70937 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -394,6 +431,7 @@ 18DF7C4027E4670600291672 /* TextRoomTimelineView.swift */, 18DF7C3E27E4670600291672 /* ImageRoomTimelineView.swift */, 18DF7C3F27E4670600291672 /* SeparatorRoomTimelineView.swift */, + 18DDB72427EC784E000F1ABF /* FormattedBodyText.swift */, ); path = Views; sourceTree = ""; @@ -445,12 +483,13 @@ 18F2BA7D27D25B4000DD1988 /* Other */ = { isa = PBXGroup; children = ( - 18F2BA7E27D25B4000DD1988 /* Routers */, - 18F2BA8727D25B4000DD1988 /* Activity */, - 18F2BA9427D25B4000DD1988 /* MXLog.swift */, - 18F2BA9527D25B4000DD1988 /* WeakDictionary */, 18F2BA9A27D25B4000DD1988 /* Coordinator.swift */, + 18F2BA9427D25B4000DD1988 /* MXLog.swift */, + 18F2BA8727D25B4000DD1988 /* Activity */, + 18ADC7F827EB02D900A8C953 /* HTMLParsing */, + 18F2BA7E27D25B4000DD1988 /* Routers */, 18F2BA9B27D25B4000DD1988 /* SwiftUI */, + 18F2BA9527D25B4000DD1988 /* WeakDictionary */, ); path = Other; sourceTree = ""; @@ -712,6 +751,7 @@ 182BC48027C4EBBB00A30C33 /* Kingfisher */, 184B31DE27D898960075A669 /* Introspect */, 18ADC7D727E4B63C00A8C953 /* MatrixRustSDK */, + 18ADC7FD27EB033400A8C953 /* DTCoreText */, ); productName = ElementX; productReference = 1850252427B6918C002E6B18 /* ElementX.app */; @@ -766,6 +806,7 @@ TargetAttributes = { 1850252327B6918C002E6B18 = { CreatedOnToolsVersion = 13.2.1; + LastSwiftMigration = 1320; }; 1850253927B6918D002E6B18 = { CreatedOnToolsVersion = 13.2.1; @@ -792,6 +833,7 @@ 182BC47F27C4EBBB00A30C33 /* XCRemoteSwiftPackageReference "Kingfisher" */, 184B31DD27D898960075A669 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 18ADC7D627E4B63C00A8C953 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */, + 18ADC7FC27EB033400A8C953 /* XCRemoteSwiftPackageReference "DTCoreText" */, ); productRefGroup = 1850252527B6918C002E6B18 /* Products */; projectDirPath = ""; @@ -879,6 +921,7 @@ 183E023627E4A79D00903BED /* RoomTimelineProviderProtocol.swift in Sources */, 18DF7C4727E4670600291672 /* TextRoomTimelineItem.swift in Sources */, 18F2BAE027D25B4000DD1988 /* NavigationRouter.swift in Sources */, + 18ADC80627EB1ED100A8C953 /* UIFont+AttributedStringBuilder.m in Sources */, 18F2BAF627D25B4000DD1988 /* Coordinator.swift in Sources */, 18F2BAEA27D25B4000DD1988 /* ActivityCenter.swift in Sources */, 18DF7C4827E4670600291672 /* ImageRoomTimelineView.swift in Sources */, @@ -886,8 +929,10 @@ 18F2BAF327D25B4000DD1988 /* WeakDictionaryReference.swift in Sources */, 18F2BB2A27D2648900DD1988 /* RoomTimelineControllerProtocol.swift in Sources */, 18F2BAF127D25B4000DD1988 /* MXLog.swift in Sources */, + 18ADC7FA27EB02D900A8C953 /* AttributedStringBuilder.swift in Sources */, 18F2BAF727D25B4000DD1988 /* StateStoreViewModel.swift in Sources */, 18F2BAF427D25B4000DD1988 /* WeakDictionary.swift in Sources */, + 18DDB72327EC7702000F1ABF /* DTHTMLElement+AttributedStringBuilder.swift in Sources */, 18C5745627E1DCA800D70937 /* RoomMessageFactory.swift in Sources */, 18F2BB1127D25B4000DD1988 /* RoomScreenModels.swift in Sources */, 18F2BADB27D25B4000DD1988 /* AuthenticationCoordinator.swift in Sources */, @@ -895,11 +940,13 @@ 18DF7C4927E4670600291672 /* SeparatorRoomTimelineView.swift in Sources */, 18F2BAE627D25B4000DD1988 /* NavigationRouterType.swift in Sources */, 18F2BAE927D25B4000DD1988 /* ActivityPresentable.swift in Sources */, + 18DDB72727EC78B8000F1ABF /* AttributedStringBuilderProtocol.swift in Sources */, 18DF7C4327E4670600291672 /* RoomTimelineItemProtocol.swift in Sources */, 18F2BAF827D25B4000DD1988 /* BindableState.swift in Sources */, 18F2BB1827D25B4000DD1988 /* LoginScreen.swift in Sources */, 18DF7C5327E4754500291672 /* MemberDetailsProvider.swift in Sources */, 18F2BAE227D25B4000DD1988 /* NavigationRouterStoreProtocol.swift in Sources */, + 18DDB72527EC784E000F1ABF /* FormattedBodyText.swift in Sources */, 18F2BB1727D25B4000DD1988 /* LoginScreenCoordinator.swift in Sources */, 18F2BAF527D25B4000DD1988 /* WeakKeyDictionary.swift in Sources */, 18F2BADF27D25B4000DD1988 /* NavigationRouterStore.swift in Sources */, @@ -921,11 +968,13 @@ 18F2BB0C27D25B4000DD1988 /* RoomScreenCoordinator.swift in Sources */, 18DF7C5027E46A7A00291672 /* EventBasedTimelineView.swift in Sources */, 18DF7C4A27E4670600291672 /* TextRoomTimelineView.swift in Sources */, + 18DDB72127EB9D57000F1ABF /* ElementXAttributeScope.swift in Sources */, 18DF7C4527E4670600291672 /* SeparatorRoomTimelineItem.swift in Sources */, 18DF7C4227E4670600291672 /* RoomTimelineItemFactory.swift in Sources */, 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 */, @@ -1113,6 +1162,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "ElementX/Supporting Files/ElementX.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -1132,6 +1182,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "ElementX/Supporting Files/ElementX-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1142,6 +1194,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "ElementX/Supporting Files/ElementX.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -1161,6 +1214,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "ElementX/Supporting Files/ElementX-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1324,6 +1378,14 @@ kind = branch; }; }; + 18ADC7FC27EB033400A8C953 /* XCRemoteSwiftPackageReference "DTCoreText" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Cocoanetics/DTCoreText"; + requirement = { + branch = develop; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1352,6 +1414,11 @@ package = 18ADC7D627E4B63C00A8C953 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */; productName = MatrixRustSDK; }; + 18ADC7FD27EB033400A8C953 /* DTCoreText */ = { + isa = XCSwiftPackageProductDependency; + package = 18ADC7FC27EB033400A8C953 /* XCRemoteSwiftPackageReference "DTCoreText" */; + productName = DTCoreText; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 1850251C27B6918C002E6B18 /* Project object */; diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index aca96b16d..7838e9a14 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,24 @@ { "object": { "pins": [ + { + "package": "DTCoreText", + "repositoryURL": "https://github.com/Cocoanetics/DTCoreText", + "state": { + "branch": "develop", + "revision": "9f515155c6fcb5d9c39150aa048685aa65b4904f", + "version": null + } + }, + { + "package": "DTFoundation", + "repositoryURL": "https://github.com/Cocoanetics/DTFoundation.git", + "state": { + "branch": null, + "revision": "76062513434421cb6c8a1ae1d4f8368a7ebc2da3", + "version": "1.7.18" + } + }, { "package": "KeychainAccess", "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess", diff --git a/ElementX/Sources/AppCoordinator.swift b/ElementX/Sources/AppCoordinator.swift index a76ec177e..4b4e8f51c 100644 --- a/ElementX/Sources/AppCoordinator.swift +++ b/ElementX/Sources/AppCoordinator.swift @@ -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, diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift new file mode 100644 index 000000000..efc6c6451 --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -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) + } + } +} diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift new file mode 100644 index 000000000..6030dc4fb --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift @@ -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]? +} diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h new file mode 100644 index 000000000..eb2cb6d24 --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.h @@ -0,0 +1,27 @@ +// +// 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 + ++ (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 diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m new file mode 100644 index 000000000..fc1174f04 --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderUtils.m @@ -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
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 diff --git a/ElementX/Sources/Other/HTMLParsing/DTHTMLElement+AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/DTHTMLElement+AttributedStringBuilder.swift new file mode 100644 index 000000000..957691381 --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/DTHTMLElement+AttributedStringBuilder.swift @@ -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"] + }() +} diff --git a/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift b/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift new file mode 100644 index 000000000..946ca8f5c --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift @@ -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(dynamicMember keyPath: KeyPath) -> T { + return self[T.self] + } +} diff --git a/ElementX/Sources/Other/HTMLParsing/UIFont+AttributedStringBuilder.h b/ElementX/Sources/Other/HTMLParsing/UIFont+AttributedStringBuilder.h new file mode 100644 index 000000000..b22ff9dc8 --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/UIFont+AttributedStringBuilder.h @@ -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 diff --git a/ElementX/Sources/Other/HTMLParsing/UIFont+AttributedStringBuilder.m b/ElementX/Sources/Other/HTMLParsing/UIFont+AttributedStringBuilder.m new file mode 100644 index 000000000..b257f0e0c --- /dev/null +++ b/ElementX/Sources/Other/HTMLParsing/UIFont+AttributedStringBuilder.m @@ -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 diff --git a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift index b0cebcbf6..45e7d443e 100644 --- a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift @@ -14,10 +14,10 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { let callbacks = PassthroughSubject() 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)) { callbacks.send(.updatedTimelineItems) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index ba6aed4ca..891f0f002 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -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 } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift index 409678796..b375acbf1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift @@ -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 diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift index 224091673..f5da6945c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift @@ -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 diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index c9924d3de..cc87a3050 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -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, diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Views/FormattedBodyText.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Views/FormattedBodyText.swift new file mode 100644 index 000000000..74b87d005 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Views/FormattedBodyText.swift @@ -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) + } + } + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Views/ImageRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Views/ImageRoomTimelineView.swift index 0f4a14aff..57627a59f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Views/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Views/ImageRoomTimelineView.swift @@ -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", diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Views/TextRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Views/TextRoomTimelineView.swift index efb5c4163..073e0e896 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Views/TextRoomTimelineView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Views/TextRoomTimelineView.swift @@ -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") diff --git a/ElementX/Supporting Files/ElementX-Bridging-Header.h b/ElementX/Supporting Files/ElementX-Bridging-Header.h new file mode 100644 index 000000000..daa15bee9 --- /dev/null +++ b/ElementX/Supporting Files/ElementX-Bridging-Header.h @@ -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"