254 lines
11 KiB
Swift
254 lines
11 KiB
Swift
//
|
|
// Copyright 2022 New Vector Ltd
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct FormattedBodyText: View {
|
|
@Environment(\.timelineStyle) private var timelineStyle
|
|
@Environment(\.layoutDirection) private var layoutDirection
|
|
|
|
private let attributedString: AttributedString
|
|
private let additionalWhitespacesCount: Int
|
|
private let boostEmojiSize: 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 boostEmojiSize,
|
|
string.containsOnlyEmoji,
|
|
let range = adjustedAttributedString.range(of: string) {
|
|
adjustedAttributedString[range].font = UIFont.systemFont(ofSize: 48.0)
|
|
}
|
|
|
|
return adjustedAttributedString.formattedComponents
|
|
}
|
|
|
|
init(attributedString: AttributedString,
|
|
additionalWhitespacesCount: Int = 0,
|
|
boostEmojiSize: Bool = false) {
|
|
self.attributedString = attributedString
|
|
self.additionalWhitespacesCount = additionalWhitespacesCount
|
|
self.boostEmojiSize = boostEmojiSize
|
|
}
|
|
|
|
init(text: String, additionalWhitespacesCount: Int = 0, boostEmojiSize: Bool = false) {
|
|
self.init(attributedString: AttributedString(text),
|
|
additionalWhitespacesCount: additionalWhitespacesCount,
|
|
boostEmojiSize: boostEmojiSize)
|
|
}
|
|
|
|
// 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 {
|
|
if timelineStyle == .bubbles {
|
|
bubbleLayout
|
|
.tint(.compound.textLinkExternal)
|
|
} else {
|
|
plainLayout
|
|
.tint(.compound.textLinkExternal)
|
|
}
|
|
}
|
|
|
|
/// The attributed components laid out for the bubbles timeline style.
|
|
var bubbleLayout: 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, timelineStyle == .bubbles ? 4 : 0)
|
|
.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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The attributed components laid out for the plain timeline style.
|
|
var plainLayout: some View {
|
|
VStack(alignment: .leading, spacing: 8.0) {
|
|
ForEach(attributedComponents) { component in
|
|
if component.isBlockquote {
|
|
HStack(spacing: 4.0) {
|
|
Rectangle()
|
|
.foregroundColor(Color.red)
|
|
.frame(width: 4.0)
|
|
MessageText(attributedString: component.attributedString)
|
|
}
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
} else {
|
|
MessageText(attributedString: component.attributedString)
|
|
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
body
|
|
.environment(\.timelineStyle, .plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
static var body: some View {
|
|
let htmlStrings = [
|
|
"""
|
|
Text before blockquote
|
|
<blockquote>
|
|
<b>bold</b> <i>italic</i>
|
|
</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 as the first item in the message</blockquote>
|
|
<p>Then another line of text here to reply to the blockquote, which is also a multiline component.</p>
|
|
<blockquote>Short line here.</blockquote>
|
|
<p>And a simple reply here.</p>
|
|
""",
|
|
"""
|
|
<code>Hello world</code>
|
|
<p>Text</p>
|
|
<code><b>Hello</b> <i>world</i></code>
|
|
<p>Text</p>
|
|
<code>Hello world</code>
|
|
""",
|
|
"<p>This is a list</p>\n<ul>\n<li>One</li>\n<li>Two</li>\n<li>And number 3</li>\n</ul>\n",
|
|
"<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>",
|
|
"<p>test</p>\n<p>test</p>"
|
|
]
|
|
|
|
let attributedStringBuilder = AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder())
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 24.0) {
|
|
ForEach(htmlStrings, id: \.self) { htmlString in
|
|
if let attributedString = attributedStringBuilder.fromHTML(htmlString) {
|
|
FormattedBodyText(attributedString: attributedString)
|
|
.previewBubble()
|
|
}
|
|
}
|
|
FormattedBodyText(attributedString: AttributedString("Some plain text wrapped in an AttributedString."))
|
|
.previewBubble()
|
|
FormattedBodyText(text: "Some plain text that's not an attributed component.")
|
|
.previewBubble()
|
|
FormattedBodyText(text: "Some plain text that's not an attributed component. This one is really long.")
|
|
.previewBubble()
|
|
|
|
FormattedBodyText(text: "❤️", boostEmojiSize: true)
|
|
.previewBubble()
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PreviewBubbleModifier: ViewModifier {
|
|
@Environment(\.timelineStyle) private var timelineStyle
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.padding(timelineStyle == .bubbles ? 8 : 0)
|
|
.background(timelineStyle == .bubbles ? Color.compound._bgBubbleOutgoing : nil)
|
|
.cornerRadius(timelineStyle == .bubbles ? 12 : 0)
|
|
.environmentObject(RoomScreenViewModel.mock.context)
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
func previewBubble() -> some View {
|
|
modifier(PreviewBubbleModifier())
|
|
}
|
|
}
|