MessageText component (#1521)

* MessageText component

* swiftformat

* fix swift format being a bit crazy

* link tapping support

* new test + fix for font + dynamic support

* fix for blockquote issue.

* code improvement

* small code improvement and fixed tests not working due to weird swiftformat behaviour

* fix boost emoji

* updated swiftformat

* better testing

* UI tests updated

* fixing the issue with the cache overriding the size category changes

* whitespaces

* appropriate color + better info plist parsing

* cleaned the code and fixed links

* tapping link fixes

* list bug fix

* ui tests regenerated
This commit is contained in:
Mauro
2023-08-22 12:14:23 +02:00
committed by GitHub
parent 269fd3d1f6
commit db1903ec05
53 changed files with 455 additions and 142 deletions
@@ -591,6 +591,15 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
selector: #selector(applicationWillTerminate),
name: UIApplication.willTerminateNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(didChangeContentSizeCategory),
name: UIContentSizeCategory.didChangeNotification,
object: nil)
}
@objc
private func didChangeContentSizeCategory() {
AttributedStringBuilder.invalidateCache()
}
@objc
@@ -30,6 +30,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
// worst singleton ever
Self.shared = self
NSTextAttachment.registerViewProviderClass(PillAttachmentViewProvider.self, forFileType: InfoPlistReader.main.pillsUTType)
return true
}
@@ -25,6 +25,10 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
private let permalinkBaseURL: URL
private static var cache = LRUCache<String, AttributedString>(countLimit: 1000)
static func invalidateCache() {
cache.removeAllValues()
}
init(permalinkBaseURL: URL) {
self.permalinkBaseURL = permalinkBaseURL
@@ -64,10 +68,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
return cached
}
// Trick DTCoreText into preserving newlines
let adjustedHTMLString = htmlString.replacingOccurrences(of: "\n", with: "<br>")
guard let data = adjustedHTMLString.data(using: .utf8) else {
guard let data = htmlString.data(using: .utf8) else {
return nil
}
+28 -11
View File
@@ -24,6 +24,13 @@ struct InfoPlistReader {
static let bundleShortVersion = "CFBundleShortVersionString"
static let bundleDisplayName = "CFBundleDisplayName"
static let mapLibreAPIKey = "mapLibreAPIKey"
static let utExportedTypeDeclarationsKey = "UTExportedTypeDeclarations"
static let utTypeIdentifierKey = "UTTypeIdentifier"
static let utDescriptionKey = "UTTypeDescription"
}
private enum Values {
static let mentionPills = "Mention Pills"
}
/// Info.plist reader on the bundle object that contains the current executable.
@@ -42,51 +49,61 @@ struct InfoPlistReader {
/// App group identifier set in Info.plist of the target
var appGroupIdentifier: String {
infoPlistStringValue(forKey: Keys.appGroupIdentifier)
infoPlistValue(forKey: Keys.appGroupIdentifier)
}
/// Base bundle identifier set in Info.plist of the target
var baseBundleIdentifier: String {
infoPlistStringValue(forKey: Keys.baseBundleIdentifier)
infoPlistValue(forKey: Keys.baseBundleIdentifier)
}
/// Keychain access group identifier set in Info.plist of the target
var keychainAccessGroupIdentifier: String {
infoPlistStringValue(forKey: Keys.keychainAccessGroupIdentifier)
infoPlistValue(forKey: Keys.keychainAccessGroupIdentifier)
}
/// Bundle executable of the target
var bundleExecutable: String {
infoPlistStringValue(forKey: kCFBundleExecutableKey as String)
infoPlistValue(forKey: kCFBundleExecutableKey as String)
}
/// Bundle identifier of the target
var bundleIdentifier: String {
infoPlistStringValue(forKey: kCFBundleIdentifierKey as String)
infoPlistValue(forKey: kCFBundleIdentifierKey as String)
}
/// Bundle short version string of the target
var bundleShortVersionString: String {
infoPlistStringValue(forKey: Keys.bundleShortVersion)
infoPlistValue(forKey: Keys.bundleShortVersion)
}
/// Bundle version of the target
var bundleVersion: String {
infoPlistStringValue(forKey: kCFBundleVersionKey as String)
infoPlistValue(forKey: kCFBundleVersionKey as String)
}
/// Bundle display name of the target
var bundleDisplayName: String {
infoPlistStringValue(forKey: Keys.bundleDisplayName)
infoPlistValue(forKey: Keys.bundleDisplayName)
}
/// Map Libre API Key
var mapLibreAPIKey: String {
infoPlistStringValue(forKey: Keys.mapLibreAPIKey)
infoPlistValue(forKey: Keys.mapLibreAPIKey)
}
private func infoPlistStringValue(forKey key: String) -> String {
guard let result = bundle.object(forInfoDictionaryKey: key) as? String else {
/// Mention Pills UTType
var pillsUTType: String {
let exportedTypes: [[String: Any]] = infoPlistValue(forKey: Keys.utExportedTypeDeclarationsKey)
guard let mentionPills = exportedTypes.first(where: { $0[Keys.utDescriptionKey] as? String == Values.mentionPills }),
let utType = mentionPills[Keys.utTypeIdentifierKey] as? String else {
fatalError("Add properly \(Values.mentionPills) exported type into your target's Info.plst")
}
return utType
}
private func infoPlistValue<T>(forKey key: String) -> T {
guard let result = bundle.object(forInfoDictionaryKey: key) as? T else {
fatalError("Add \(key) into your target's Info.plst")
}
return result
@@ -0,0 +1,137 @@
//
// Copyright 2023 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
import UIKit
final class MessageTextView: UITextView {
override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
// Prevent long press to show the magnifying glass
if gestureRecognizer is UILongPressGestureRecognizer {
gestureRecognizer.isEnabled = false
}
super.addGestureRecognizer(gestureRecognizer)
}
}
struct MessageText: UIViewRepresentable {
@Environment(\.openURL) private var openURLAction: OpenURLAction
let attributedString: AttributedString
func makeUIView(context: Context) -> MessageTextView {
let textView = MessageTextView()
textView.isEditable = false
textView.isScrollEnabled = false
textView.adjustsFontForContentSizeCategory = true
// Required to allow tapping links
// We disable selection at delegate level
textView.isSelectable = true
textView.isUserInteractionEnabled = true
// Otherwise links can be dragged and dropped when long pressed
textView.textDragInteraction?.isEnabled = false
textView.contentInset = .zero
textView.contentInsetAdjustmentBehavior = .never
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.layoutManager.usesFontLeading = false
textView.backgroundColor = .clear
textView.attributedText = NSAttributedString(attributedString)
textView.delegate = context.coordinator
return textView
}
func updateUIView(_ uiView: MessageTextView, context: Context) {
uiView.attributedText = NSAttributedString(attributedString)
context.coordinator.openURLAction = openURLAction
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: MessageTextView, context: Context) -> CGSize? {
uiView.sizeThatFits(CGSize(width: proposal.width ?? UIView.layoutFittingExpandedSize.width, height: UIView.layoutFittingCompressedSize.height))
}
func makeCoordinator() -> Coordinator {
Coordinator(openURLAction: openURLAction)
}
final class Coordinator: NSObject, UITextViewDelegate {
var openURLAction: OpenURLAction
init(openURLAction: OpenURLAction) {
self.openURLAction = openURLAction
}
func textViewDidChangeSelection(_ textView: UITextView) {
textView.selectedTextRange = nil
}
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
if interaction == .invokeDefaultAction {
openURLAction.callAsFunction(URL)
}
return false
}
}
}
// MARK: - Previews
struct MessageText_Previews: PreviewProvider {
private static let defaultFontContainer: AttributeContainer = {
var container = AttributeContainer()
container.font = UIFont.preferredFont(forTextStyle: .body)
return container
}()
private static let attributedString = AttributedString("Hello World! Hello world! Hello world! Hello world! Hello World! Hellooooooooooooooooooooooo Woooooooooooooooooooooorld", attributes: defaultFontContainer)
private static let attributedStringWithAttachment = "Hello " + AttributedString(NSAttributedString(attachment: PillTextAttachment(data: Data(), ofType: InfoPlistReader.main.pillsUTType))) + " World!"
private static let htmlStringWithQuote =
"""
<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>
"""
private static let htmlStringWithList = "<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"
private static let attributedStringBuilder = AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL)
static var previews: some View {
MessageText(attributedString: attributedString)
.border(Color.purple)
.previewDisplayName("Custom Text")
// For comparison
Text(attributedString)
.border(Color.purple)
.previewDisplayName("SwiftUI Default Text")
MessageText(attributedString: attributedStringWithAttachment)
.border(Color.purple)
.previewDisplayName("Custom Attachment")
if let attributedString = attributedStringBuilder.fromHTML(htmlStringWithQuote) {
MessageText(attributedString: attributedString)
.border(Color.purple)
.previewDisplayName("With block quote")
}
if let attributedString = attributedStringBuilder.fromHTML(htmlStringWithList) {
MessageText(attributedString: attributedString)
.border(Color.purple)
.previewDisplayName("With list")
}
}
}
@@ -0,0 +1,35 @@
//
// Copyright 2023 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
import UIKit
final class PillAttachmentViewProvider: NSTextAttachmentViewProvider {
// MARK: - Override
override func loadView() {
super.loadView()
guard textAttachment is PillTextAttachment else {
MXLog.failure("[PillAttachmentViewProvider]: attachment is missing or not of expected class")
return
}
let view = PillView()
let controller = UIHostingController(rootView: view)
self.view = controller.view
}
}
@@ -0,0 +1,20 @@
//
// Copyright 2023 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 UIKit
/// Text attachment for pills display.
final class PillTextAttachment: NSTextAttachment { }
@@ -0,0 +1,35 @@
//
// Copyright 2023 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 PillView: View {
var body: some View {
Button(action: { MXLog.info("TEXT ATTACHMENT TEST") }) {
HStack {
Image(asset: Asset.Images.launchLogo)
.resizable()
.scaledToFit()
}
}
}
}
struct PillView_Previews: PreviewProvider {
static var previews: some View {
PillView()
}
}
@@ -19,21 +19,31 @@ 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
adjustedAttributedString.append(AttributedString(stringLiteral: additionalWhitespacesSuffix))
var adjustedAttributedString = attributedString + AttributedString(additionalWhitespacesSuffix)
// 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 = .system(size: 48.0)
adjustedAttributedString[range].font = UIFont.systemFont(ofSize: 48.0)
}
return adjustedAttributedString.formattedComponents
@@ -52,7 +62,7 @@ struct FormattedBodyText: View {
additionalWhitespacesCount: additionalWhitespacesCount,
boostEmojiSize: boostEmojiSize)
}
// These is needed to create the slightly off inlined timestamp effect
private var additionalWhitespacesSuffix: String {
.generateBreakableWhitespaceEnd(whitespaceCount: additionalWhitespacesCount, layoutDirection: layoutDirection)
@@ -75,8 +85,7 @@ struct FormattedBodyText: View {
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.
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
.foregroundColor(.compound.textSecondary)
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 12.0)
@@ -90,10 +99,9 @@ struct FormattedBodyText: View {
}
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
} else {
Text(component.attributedString)
MessageText(attributedString: component.attributedString)
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.compound.textPrimary)
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
}
}
@@ -102,7 +110,7 @@ struct FormattedBodyText: View {
// which are used for layout calculations but won't be rendered.
ForEach(attributedComponents, id: \.self) { component in
if component.isBlockquote {
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 12.0)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
@@ -121,23 +129,25 @@ struct FormattedBodyText: View {
Rectangle()
.foregroundColor(Color.red)
.frame(width: 4.0)
Text(component.attributedString)
.foregroundColor(.compound.textPrimary)
MessageText(attributedString: component.attributedString)
}
.fixedSize(horizontal: false, vertical: true)
} else {
Text(component.attributedString)
MessageText(attributedString: component.attributedString)
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.compound.textPrimary)
}
}
}
}
private var blockquoteAttributes: AttributeContainer {
var container = AttributeContainer()
container.font = .compound.bodyMD
// 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
// To remove the block style paragraph that the parser adds by default
container.paragraphStyle = .default
return container
}
}
@@ -177,24 +187,32 @@ struct FormattedBodyText_Previews: PreviewProvider {
<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"
]
let attributedStringBuilder = AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL)
VStack(alignment: .leading, spacing: 24.0) {
ForEach(htmlStrings, id: \.self) { htmlString in
if let attributedString = attributedStringBuilder.fromHTML(htmlString) {
FormattedBodyText(attributedString: attributedString)
.previewBubble()
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()
}
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()
.padding()
}
.padding()
}
}
@@ -169,16 +169,6 @@ class TimelineTableViewController: UIViewController {
.id(id)
.frame(maxWidth: .infinity, alignment: .leading)
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
.onAppear {
coordinator.send(viewAction: .itemAppeared(itemID: viewState.identifier))
}
.onDisappear {
coordinator.send(viewAction: .itemDisappeared(itemID: viewState.identifier))
}
.environment(\.openURL, OpenURLAction { url in
coordinator.send(viewAction: .linkClicked(url: url))
return .systemAction
})
}
.margins(.all, self.timelineStyle.rowInsets)
.minSize(height: 1)
@@ -23,7 +23,6 @@ struct RoomTimelineItemView: View {
timelineView
.animation(.elementDefault, value: viewState.groupStyle)
.animation(.elementDefault, value: viewState.type)
.environmentObject(context)
.environment(\.timelineGroupStyle, viewState.groupStyle)
.onAppear {
context.send(viewAction: .itemAppeared(itemID: viewState.identifier))