diff --git a/ElementX/Sources/Other/Extensions/AttributedString.swift b/ElementX/Sources/Other/Extensions/AttributedString.swift index e045cd297..d82baa26b 100644 --- a/ElementX/Sources/Other/Extensions/AttributedString.swift +++ b/ElementX/Sources/Other/Extensions/AttributedString.swift @@ -17,10 +17,11 @@ nonisolated extension AttributedString { var formattedComponents: [AttributedStringBuilderComponent] { var components = [AttributedStringBuilderComponent]() - for run in runs[\.blockquote, \.codeBlock] { + for run in runs[\.blockquote, \.codeBlock, \.details] { let isBlockquote = run.0 != nil let isCodeBlock = run.1 != nil - var attributedString = AttributedString(self[run.2]) + let details = run.2 + var attributedString = AttributedString(self[run.3]) // Remove trailing new lines if any if attributedString.characters.last?.isNewline ?? false, @@ -28,12 +29,14 @@ nonisolated extension AttributedString { attributedString.removeSubrange(range) } - let componentType: AttributedStringBuilderComponent.ComponentType = switch (isBlockquote, isCodeBlock) { - case (true, _): + let componentType: AttributedStringBuilderComponent.ComponentType = switch (details, isBlockquote, isCodeBlock) { + case (.some(let summary), _, _): + .details(summary: summary) + case (_, true, _): .blockquote - case (false, true): + case (_, false, true): .codeBlock - case (false, false): + case (_, false, false): .plainText } diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index b3f56cda7..dc70ecf5d 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -24,6 +24,7 @@ nonisolated protocol MentionBuilderProtocol: Sendable { nonisolated extension NSAttributedString.Key { static let MatrixBlockquote: NSAttributedString.Key = .init(rawValue: BlockquoteAttribute.name) + static let MatrixDetails: NSAttributedString.Key = .init(rawValue: DetailsAttribute.name) static let MatrixUserID: NSAttributedString.Key = .init(rawValue: UserIDAttribute.name) static let MatrixUserDisplayName: NSAttributedString.Key = .init(rawValue: UserDisplayNameAttribute.name) static let MatrixRoomDisplayName: NSAttributedString.Key = .init(rawValue: RoomDisplayNameAttribute.name) @@ -203,6 +204,22 @@ nonisolated struct AttributedStringBuilder: AttributedStringBuilderProtocol { content = attributedString(element: childElement, documentBody: documentBody, preserveFormatting: preserveFormatting, listTag: listTag, listIndex: &childIndex, indentLevel: indentLevel) content.addAttribute(.MatrixBlockquote, value: true, range: NSRange(location: 0, length: content.length)) + case "details": + // Pull the summary out of the tree so that it isn't rendered inline with the content, + // it is used as the title of the collapsed component instead. + let summaryElement = childElement.getChildNodes() + .lazy + .compactMap { $0 as? Element } + .first { $0.tagName().lowercased() == "summary" } + let summary = summaryElement.flatMap { try? $0.text() } ?? "" + try? summaryElement?.remove() + + content = attributedString(element: childElement, documentBody: documentBody, preserveFormatting: preserveFormatting, listTag: listTag, listIndex: &childIndex, indentLevel: indentLevel) + + // Browsers fall back to a default title when the summary is missing, match them. + let title = summary.isEmpty ? L10n.a11yViewDetails : summary + content.addAttribute(.MatrixDetails, value: title, range: NSRange(location: 0, length: content.length)) + case "code", "pre": let isCodeBlock = tag == "pre" diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift index 0e415ebad..ad7c9461b 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift @@ -9,10 +9,11 @@ import Foundation nonisolated struct AttributedStringBuilderComponent: Hashable, Identifiable { - enum ComponentType { + enum ComponentType: Hashable { case plainText case blockquote case codeBlock + case details(summary: String) } /// Identifier for the `Identifiable` conformance, allows edits to the `FormattedBodyText` to animate seamlessly diff --git a/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift b/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift index 7d427083c..391637bdb 100644 --- a/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift +++ b/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift @@ -8,11 +8,6 @@ import Foundation -enum BlockquoteAttribute: AttributedStringKey { - typealias Value = Bool - static let name = "MXBlockquoteAttribute" -} - enum UserIDAttribute: AttributedStringKey { typealias Value = String static let name = "MXUserIDAttribute" @@ -65,6 +60,17 @@ enum AllUsersMentionAttribute: AttributedStringKey { static let name = "MXAllUsersMentionAttribute" } +enum BlockquoteAttribute: AttributedStringKey { + typealias Value = Bool + static let name = "MXBlockquoteAttribute" +} + +/// Marks the content of a `
` element, carrying its `` as the value. +enum DetailsAttribute: AttributedStringKey { + typealias Value = String + static let name = "MXDetailsAttribute" +} + enum CodeBlockAttribute: AttributedStringKey { typealias Value = Bool static let name = "MXCodeBlockAttribute" @@ -78,6 +84,7 @@ enum InlineCodeAttribute: AttributedStringKey { nonisolated extension AttributeScopes { struct ElementXAttributes: AttributeScope { let blockquote: BlockquoteAttribute + let details: DetailsAttribute let userID: UserIDAttribute // periphery:ignore - required to make NSAttributedString to AttributedString conversion even if not used directly diff --git a/ElementX/Sources/Other/HTMLParsing/HTMLFixtures.swift b/ElementX/Sources/Other/HTMLParsing/HTMLFixtures.swift index 3afeb7bf2..dfe4070c4 100644 --- a/ElementX/Sources/Other/HTMLParsing/HTMLFixtures.swift +++ b/ElementX/Sources/Other/HTMLParsing/HTMLFixtures.swift @@ -19,6 +19,7 @@ nonisolated enum HTMLFixtures: String, CaseIterable { case wideCodeBlock case unorderedList case orderedList + case details var rawValue: String { switch self { @@ -123,6 +124,13 @@ nonisolated enum HTMLFixtures: String, CaseIterable {
  • Skittles
  • """ + case .details: + """ + Text before the details\ +
    Expand me

    Hidden content with a link in it.

    \ +

    These details have no summary.

    \ + Text after the details + """ } } } diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift index 0d59afeb1..5b627c4a3 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift @@ -6,6 +6,7 @@ // Please see LICENSE files in the repository root for full details. // +import Compound import SwiftUI struct FormattedBodyText: View { @@ -50,10 +51,15 @@ struct FormattedBodyText: View { boostFontSize: boostFontSize) } + /// Details components contain a control that VoiceOver needs to reach, so their children can't be merged away. + private var containsDetails: Bool { + attributedString.runs[\.details].contains { $0.0 != nil } + } + var body: some View { layout .tint(.compound.textLinkExternal) - .accessibilityElement(children: .ignore) + .accessibilityElement(children: containsDetails ? .contain : .ignore) .accessibilityLabel(Text(attributedString)) } @@ -90,6 +96,10 @@ struct FormattedBodyText: View { UIPasteboard.general.string = component.attributedString.string } } + case .details(let summary): + DetailsView(summary: summary, attributedString: component.attributedString) + .padding(.horizontal, 4) + .timelineBubbleLayoutSize(.natural) case .plainText: MessageText(attributedString: component.attributedString, trailingReservedSize: index == lastPlainTextIndex ? trailingReservedSize : .zero) @@ -111,7 +121,7 @@ struct FormattedBodyText: View { CodeBlockView(attributedString: component.attributedString, mode: .layout) .timelineBubbleLayoutSize(.bubbleWidth(mode: .layout)) .hidden() - case .plainText: + case .details, .plainText: EmptyView() } } @@ -157,6 +167,34 @@ struct FormattedBodyText: View { } } + /// The view used to render a `
    ` component, collapsing its content behind its ``. + private struct DetailsView: View { + let summary: String + let attributedString: AttributedString + + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { isExpanded.toggle() } label: { + HStack(alignment: .center, spacing: 4) { + CompoundIcon(\.chevronRight, size: .xSmall, relativeTo: .compound.bodyLG) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + Text(summary) + } + .font(.compound.bodyLGSemibold) + .foregroundStyle(.compound.textPrimary) + } + .buttonStyle(.plain) + + if isExpanded { + MessageText(attributedString: attributedString) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + /// The view used to render a code block component. It can be configured in one of 2 modes: /// - `.layout`: The view is given it's natural size to be used for layout calculations. /// - `.rendering`: The view has a greedy width that, in combination with the custom layout, @@ -221,6 +259,7 @@ struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
    A
    B

    C

    +
    D

    E

    """ if let attributedString = attributedStringBuilder.fromHTML(html) { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPad-en-GB.png new file mode 100644 index 000000000..c5f478485 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39686f72f73b8d3f48e3c4b42e233e036829d43bd1a47d7e2906afc83f525eda +size 75521 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPad-pseudo.png new file mode 100644 index 000000000..52678b809 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9eedcccf26572717005a82b575730b9ed9695435eed7a27805fdbc965c098156 +size 76121 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPhone-en-GB.png new file mode 100644 index 000000000..b87e320c7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e22425cea98a0ebdd00570a0e48dea3aed054073a7445bbcb7d55bd4f689095 +size 86140 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPhone-pseudo.png new file mode 100644 index 000000000..1a7a3feee --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.details-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43694f1c9bade1c326cece0d5a57796b6c10d164eb270a9786bc590a628949c2 +size 89753 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPad-en-GB.png index d8f7c18ee..fe264ddcf 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2eb1583f31787e8d2384774a587c316f5192c5992e77063603f503441326e791 -size 16598 +oid sha256:aab3c9f39d91466df6b84cffc9f2086eeac62173ecc09783d2a18d35a7f7c757 +size 20261 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPad-pseudo.png index d8f7c18ee..fe264ddcf 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2eb1583f31787e8d2384774a587c316f5192c5992e77063603f503441326e791 -size 16598 +oid sha256:aab3c9f39d91466df6b84cffc9f2086eeac62173ecc09783d2a18d35a7f7c757 +size 20261 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPhone-en-GB.png index 33c6acfb8..32cc04b1c 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18dc7d51d592ac2578889091dd0fca2f8058e43557834092e8591994519d85b3 -size 9669 +oid sha256:cc3f47134da1c0cd92311fd9949189c1388403eaf747f1e23af32dfa0ea389fa +size 11667 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPhone-pseudo.png index 33c6acfb8..32cc04b1c 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/formattedBodyText.singleColumnComponents-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18dc7d51d592ac2578889091dd0fca2f8058e43557834092e8591994519d85b3 -size 9669 +oid sha256:cc3f47134da1c0cd92311fd9949189c1388403eaf747f1e23af32dfa0ea389fa +size 11667 diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index c2a71cd0a..b33c44cfc 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -242,6 +242,31 @@ struct AttributedStringBuilderTests { #expect(foundLink) } + @Test + func detailsAndSummary() throws { + let htmlString = "Before
    Expand me

    Hidden content

    After" + + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") + + #expect(String(attributedString.characters) == "BeforeHidden content\nAfter") + + let components = attributedString.formattedComponents + + #expect(components.count == 3) + #expect(components.map(\.type) == [.plainText, .details(summary: "Expand me"), .plainText]) + #expect(components[1].attributedString.string == "Hidden content") + } + + @Test + func detailsWithoutSummary() throws { + let htmlString = "

    Content

    " + + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") + + #expect(String(attributedString.characters) == "Content") + #expect(attributedString.formattedComponents.map(\.type) == [.details(summary: L10n.a11yViewDetails)]) + } + @Test func singleBlockquote() throws { let htmlString = "
    Blockquote

    Another paragraph

    "