Files
element-x-ios/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift
T
2025-09-09 17:17:40 +01:00

264 lines
12 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
struct FormattedBodyText: View {
@Environment(\.layoutDirection) private var layoutDirection
private let attributedString: AttributedString
private let additionalWhitespacesCount: Int
private let boostFontSize: Bool
private let defaultAttributesContainer: AttributeContainer = {
var container = AttributeContainer()
// Equivalent to compound's bodyLG
container.font = UIFont.preferredFont(forTextStyle: .body)
container.foregroundColor = UIColor.compound.textPrimary
return container
}()
private var attributedComponents: [AttributedStringBuilderComponent] {
var adjustedAttributedString = attributedString + AttributedString(additionalWhitespacesSuffix)
// If this is not a list, force the writing direction by adding the correct unicode character.
if !String(attributedString.characters).starts(with: "\t") {
adjustedAttributedString = AttributedString(layoutDirection.isolateLayoutUnicodeString) + adjustedAttributedString
}
// Required to allow the underlying TextView to use body font when no font is specifie in the AttributedString.
adjustedAttributedString.mergeAttributes(defaultAttributesContainer, mergePolicy: .keepCurrent)
let string = String(attributedString.characters)
if boostFontSize, let range = adjustedAttributedString.range(of: string) {
adjustedAttributedString[range].font = UIFont.systemFont(ofSize: 48.0)
}
return adjustedAttributedString.formattedComponents
}
init(attributedString: AttributedString,
additionalWhitespacesCount: Int = 0,
boostFontSize: Bool = false) {
self.attributedString = attributedString
self.additionalWhitespacesCount = additionalWhitespacesCount
self.boostFontSize = boostFontSize
}
init(text: String, additionalWhitespacesCount: Int = 0, boostFontSize: Bool = false) {
self.init(attributedString: AttributedString(text),
additionalWhitespacesCount: additionalWhitespacesCount,
boostFontSize: boostFontSize)
}
// These is needed to create the slightly off inlined timestamp effect
private var additionalWhitespacesSuffix: String {
.generateBreakableWhitespaceEnd(whitespaceCount: additionalWhitespacesCount, layoutDirection: layoutDirection)
}
var body: some View {
mainContent
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(attributedString))
}
@ViewBuilder
var mainContent: some View {
layout
.tint(.compound.textLinkExternal)
}
/// The attributed components laid out for the bubbles timeline style.
var layout: some View {
TimelineBubbleLayout(spacing: 8) {
ForEach(attributedComponents) { component in
// 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)
.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 {
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 12.0)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
.hidden()
}
}
}
}
private var blockquoteAttributes: AttributeContainer {
// The paragraph style removes the block style paragraph that the parser adds by default
// Set directly in the constructor to avoid `Conformance to 'Sendable'` warnings
var container = AttributeContainer([.paragraphStyle: NSParagraphStyle.default])
// Sadly setting SwiftUI fonts do not work so we would need UIFont equivalents for compound, this one is bodyMD
container.font = UIFont.preferredFont(forTextStyle: .subheadline)
container.foregroundColor = UIColor.compound.textSecondary
return container
}
}
// MARK: - Previews
struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
body(AttributedStringBuilderV1(cacheKey: "v1", mentionBuilder: MentionBuilder()))
.previewLayout(.sizeThatFits)
body(AttributedStringBuilderV2(cacheKey: "v2", mentionBuilder: MentionBuilder()))
.previewLayout(.sizeThatFits)
}
@ViewBuilder
// swiftlint:disable:next function_body_length
static func body(_ attributedStringBuilder: AttributedStringBuilderProtocol) -> some View {
let htmlStrings = [
"""
Nothing is as permanent as a temporary solution that works.
Experience is the name everyone gives to their mistakes.
If debugging is the process of removing bugs, then programming must be the process of putting them in.
""",
"""
<h1>H1 Header</h1></br>
<h2>H2 Header</h2></br>
<h3>H3 Header</h3></br>
<h4>H4 Header</h4></br>
<h5>H5 Header</h5></br>
<h6>H6 Header</h6>
""",
"""
<p>This is a paragraph.</p><p>And this is another one.</p>
<div>And this is a division.</div>
New lines are ignored.\n\nLike so.</br>
But this line comes after a line break.</br>
""",
"""
We expect various identifiers to be (partially) detected:</br>
!room:matrix.org, #room:matrix.org, $event:matrix.org, @user:matrix.org</br>
matrix://roomid/room:matrix.org, matrix://r/room:matrix.org, matrix://roomid/room:matrix.org/e/event:matrix.org, matrix://roomid/room:matrix.org/u/user:matrix.org</br>
""",
"""
Links too:</br><a href=\"https://www.matrix.org/\">Matrix rules! 🤘</a>, matrix.org, www.matrix.org, http://matrix.org
""",
"""
<b>Text</b> <i>formatting</i> <u>should</u> <s>work</s> properly.</br>
<strong>Text</strong> <em>formatting</em> does <del>work!</del>.</br>
<b>And <i>mixed</i></b> <em><s>formatting</s></em> <del><strong>works</strong></del> <u><b>too!!1!</b></u>.
<br>
<sup>Thumbs</sup> if you liked it, <sub>sub</sub> if you loved it!
""",
"""
Text before blockquote<blockquote><b>Nothing</b> <i>is</i> as permanent as a <u>temporary<u> solution that <a href=\"https://www.matrix.org/\">works.</blockquote>Text after blockquote
""",
"""
<blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
<blockquote>Second blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
<blockquote>Third blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
""",
"""
<blockquote>A blockquote that is long and goes onto multiple lines </blockquote>
<p>Then another line of text to reply to the blockquote, also multiline.</p>
<blockquote>Then a short blockquote.</blockquote><p>Followed by another short sentence.</p>
""",
"""
<pre>A preformatted code block<code>struct ContentView: View {
var body: some View {
VStack {
Text("Knock, knock!")
.padding()
.background(Color.yellow, in: RoundedRectangle(cornerRadius: 8))
Text("Who's there?")
}
.padding()
}
}</code></pre></br>
Followed by some plain code blocks</br>
<code>Hello, world!</code>
<code><b>Hello</b>, <i>world!</i></code>
<code><b>Hello</b>, <a href="https://www.matrix.org">world!</a></code>
""",
"""
This is an unordered list
<ul>
<li>Jones’ <b>Crumpets</b></li>
<li><i>Crumpetorium<i></li>
<li>Village <u>Bakery</u></li>
</ul>
""",
"""
This is an ordered list
<ol>
<li>Jelly Belly</li>
<li>Starburst</li>
<li>Skittles</li>
</ol>
"""
]
ScrollView {
VStack(alignment: .leading, spacing: 4.0) {
ForEach(htmlStrings, id: \.self) { htmlString in
HStack(alignment: .top, spacing: 0) {
Text(htmlString)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(4.0)
Divider()
.background(.black)
if let attributedString = attributedStringBuilder.fromHTML(htmlString) {
FormattedBodyText(attributedString: attributedString)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.bubbleBackground()
.padding(4.0)
}
}
.border(.black)
}
FormattedBodyText(attributedString: AttributedString("Some plain text wrapped in an AttributedString."))
.bubbleBackground()
FormattedBodyText(text: "Some plain text that's not an attributed component.")
.bubbleBackground()
FormattedBodyText(text: "❤️", boostFontSize: true)
.bubbleBackground()
}
.padding()
}
}
}