From 11af2bb0ca27196167cf8c170c71eaa60c15630d Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Mon, 26 Jan 2026 12:39:55 +0200 Subject: [PATCH] Horizontally scrollable code blocks (#5001) * Remove attributed string backed codeblock background color * Add code block support to attributed string componentization * Render code blocks within their own custom horizontal scroll view within the timeline * Update preview test snapshots * Introduce a attributed string component type instead of a 2 different booleans. --- .../Other/Extensions/AttributedString.swift | 25 ++++++-- .../HTMLParsing/AttributedStringBuilder.swift | 1 - .../AttributedStringBuilderProtocol.swift | 8 ++- .../TimelineItemViews/FormattedBodyText.swift | 57 ++++++++++++------- .../formattedBodyText.iPad-en-GB-0.png | 4 +- .../formattedBodyText.iPad-pseudo-0.png | 4 +- .../formattedBodyText.iPhone-en-GB-0.png | 4 +- .../formattedBodyText.iPhone-pseudo-0.png | 4 +- .../AttributedStringBuilderTests.swift | 4 +- 9 files changed, 75 insertions(+), 36 deletions(-) diff --git a/ElementX/Sources/Other/Extensions/AttributedString.swift b/ElementX/Sources/Other/Extensions/AttributedString.swift index bf276c685..708786077 100644 --- a/ElementX/Sources/Other/Extensions/AttributedString.swift +++ b/ElementX/Sources/Other/Extensions/AttributedString.swift @@ -15,8 +15,12 @@ extension AttributedString { } var formattedComponents: [AttributedStringBuilderComponent] { - runs[\.blockquote].map { value, range in - var attributedString = AttributedString(self[range]) + var components = [AttributedStringBuilderComponent]() + + for run in runs[\.blockquote, \.codeBlock] { + let isBlockquote = run.0 != nil + let isCodeBlock = run.1 != nil + var attributedString = AttributedString(self[run.2]) // Remove trailing new lines if any if attributedString.characters.last?.isNewline ?? false, @@ -24,10 +28,21 @@ extension AttributedString { attributedString.removeSubrange(range) } - let isBlockquote = value != nil - - return AttributedStringBuilderComponent(id: String(attributedString.characters), attributedString: attributedString, isBlockquote: isBlockquote) + let componentType: AttributedStringBuilderComponent.ComponentType = switch (isBlockquote, isCodeBlock) { + case (true, _): + .blockquote + case (false, true): + .codeBlock + case (false, false): + .plainText + } + + components.append(AttributedStringBuilderComponent(id: String(attributedString.characters), + attributedString: attributedString, + type: componentType)) } + + return components } /// Replaces the specified placeholder with a string that links to the specified URL. diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index 6b50b7a85..147a9e47a 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -204,7 +204,6 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { content.setFontPreservingSymbolicTraits(UIFont.monospacedSystemFont(ofSize: fontPointSize, weight: .regular)) content.addAttribute(.CodeBlock, value: true, range: NSRange(location: 0, length: content.length)) - content.addAttribute(.backgroundColor, value: UIColor.compound._bgCodeBlock as Any, range: NSRange(location: 0, length: content.length)) // Don't allow identifiers or links in code blocks content.removeAttribute(.MatrixRoomID, range: NSRange(location: 0, length: content.length)) diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift index 1bd21e98d..ff5c380ca 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift @@ -9,9 +9,15 @@ import Foundation struct AttributedStringBuilderComponent: Hashable, Identifiable { + enum ComponentType { + case plainText + case blockquote + case codeBlock + } + let id: String let attributedString: AttributedString - let isBlockquote: Bool + let type: ComponentType } protocol AttributedStringBuilderProtocol { diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift index 5ebafb3c3..c9f72d4c1 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift @@ -81,34 +81,53 @@ struct FormattedBodyText: View { // Ignore if the string contains only the layout correction if String(component.attributedString.characters) == layoutDirection.isolateLayoutUnicodeString { EmptyView() - } else if component.isBlockquote { - // The rendered blockquote with a greedy width. The custom layout prevents the - // infinite width from increasing the overall width of the view. - MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes)) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 12.0) - .overlay(alignment: .leading) { - // User an overlay here so that the rectangle's infinite height doesn't take priority - Capsule() - .frame(width: 2.0) - .padding(.leading, 5.0) - .foregroundColor(.compound.textSecondary) - .padding(.vertical, 2) - } - .layoutPriority(TimelineBubbleLayout.Priority.visibleQuote) } else { - MessageText(attributedString: component.attributedString) - .padding(.horizontal, 4) + switch component.type { + case .blockquote: + // The rendered blockquote with a greedy width. The custom layout prevents the + // infinite width from increasing the overall width of the view. + MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 12.0) + .overlay(alignment: .leading) { + // User an overlay here so that the rectangle's infinite height doesn't take priority + Capsule() + .frame(width: 2.0) + .padding(.leading, 5.0) + .foregroundColor(.compound.textSecondary) + .padding(.vertical, 2) + } + .layoutPriority(TimelineBubbleLayout.Priority.visibleQuote) + case .codeBlock: + ScrollView(.horizontal) { + MessageText(attributedString: component.attributedString) + .padding([.horizontal, .top], 4) + .padding(.bottom, 8) + } + .background(.compound._bgCodeBlock) + .scrollIndicatorsFlash(onAppear: true) .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 4) .layoutPriority(TimelineBubbleLayout.Priority.regularText) + .contextMenu { + Button(L10n.actionCopy) { + UIPasteboard.general.string = component.attributedString.string + } + } + case .plainText: + MessageText(attributedString: component.attributedString) + .padding(.horizontal, 4) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(TimelineBubbleLayout.Priority.regularText) + } } } // Make a second iteration through the components adding fixed width blockquotes // which are used for layout calculations but won't be rendered. ForEach(attributedComponents) { component in - if component.isBlockquote { + if case .blockquote = component.type { MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes)) .fixedSize(horizontal: false, vertical: true) .padding(.leading, 12.0) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPad-en-GB-0.png index 3cd3030c1..dbe73fadc 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bce958a2c9eedbc8b4e63c7dc135dff80b249f64c812f47405400e1e2d314e22 -size 1069392 +oid sha256:c6a74364c92fe9499ded638a5522fc8df5bb9b7905a835d1d6db0324dbd4c9e0 +size 1072804 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPad-pseudo-0.png index 3cd3030c1..dbe73fadc 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bce958a2c9eedbc8b4e63c7dc135dff80b249f64c812f47405400e1e2d314e22 -size 1069392 +oid sha256:c6a74364c92fe9499ded638a5522fc8df5bb9b7905a835d1d6db0324dbd4c9e0 +size 1072804 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPhone-en-GB-0.png index 9bbe78ed9..78996a2d2 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5e5b587c34f19aebcefabf0d9893dfde863390348132483ac8763387b692dc4 -size 1252781 +oid sha256:ac247697b25608e642347847d808e4b88ed45dfdcdef279e7147a95e894b78ca +size 1186697 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPhone-pseudo-0.png index 9bbe78ed9..78996a2d2 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5e5b587c34f19aebcefabf0d9893dfde863390348132483ac8763387b692dc4 -size 1252781 +oid sha256:ac247697b25608e642347847d808e4b88ed45dfdcdef279e7147a95e894b78ca +size 1186697 diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index 3b61d9088..19ba32786 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -349,14 +349,14 @@ class AttributedStringBuilderTests: XCTestCase { } let coalescedComponents = attributedString.formattedComponents - XCTAssertEqual(coalescedComponents.count, 1) + XCTAssertEqual(coalescedComponents.count, 3) guard let component = coalescedComponents.first else { XCTFail("Could not get the first component") return } - XCTAssertTrue(component.isBlockquote, "The reply quote should be a blockquote.") + XCTAssertTrue(component.type == .blockquote, "The reply quote should be a blockquote.") } func testMultipleGroupedBlockquotes() {