mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Pill View (#1797)
* provider can now check the current session * More testable code * created the test condition * it works but not always not sure why, need to dig deeper * sadly we need to use textkit 1 to solve this issue * removed developer option screen test * this experimental solution kinda works but I need a way to pill recomputation is weird * format * display improvement * better and faster solution * pilished the code * better coloring * swift format * just need to solve the caching issue * fix caching issue * tests done! * changelog * pr comments addressed * all pr comments addressed * docs * line lenght * updated tests and fixed a parsing permalink issue * MentionBuilder * pr comments * swiftformat * code blocks should not have links * Update ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> * Update ElementX/Sources/Services/Client/ClientProxy.swift Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> * Update UnitTests/Sources/AttributedStringBuilderTests.swift Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> * Update ElementX/Sources/UITests/UITestsAppCoordinator.swift Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> * Update ElementX/Sources/Other/Pills/PillAttachmentViewProvider.swift Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> * Update ElementX/Sources/Other/Pills/MentionBuilder.swift Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> * pr comments * swiftformat --------- Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import UIKit
|
||||
|
||||
struct MentionBuilder: MentionBuilderProtocol {
|
||||
// Can be removed when mentions are enabled by default
|
||||
let mentionsEnabled: Bool
|
||||
|
||||
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String) {
|
||||
guard mentionsEnabled else {
|
||||
attributedString.addAttributes([.MatrixUserID: userID], range: range)
|
||||
return
|
||||
}
|
||||
|
||||
let attributes = attributedString.attributes(at: 0, longestEffectiveRange: nil, in: range)
|
||||
let font = attributes[.font] as? UIFont ?? .preferredFont(forTextStyle: .body)
|
||||
let blockquote = attributes[.MatrixBlockquote]
|
||||
|
||||
let attachmentData = PillTextAttachmentData(type: .user(userID: userID), font: font)
|
||||
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
|
||||
attributedString.addAttributes([.MatrixUserID: userID], range: range)
|
||||
return
|
||||
}
|
||||
|
||||
var attributesToAdd: [NSAttributedString.Key: Any] = [.link: url, .MatrixUserID: userID]
|
||||
if let blockquote {
|
||||
// mentions can be in blockquotes, so if the replaced string was in one, we keep the attribute
|
||||
attributesToAdd[.MatrixBlockquote] = blockquote
|
||||
}
|
||||
let attachmentString = NSMutableAttributedString(attachment: attachment)
|
||||
attachmentString.addAttributes(attributes, range: NSRange(location: 0, length: attachmentString.length))
|
||||
attributedString.replaceCharacters(in: range, with: attachmentString)
|
||||
}
|
||||
}
|
||||
@@ -15,21 +15,49 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
final class MessageTextView: UITextView {
|
||||
var roomContext: RoomScreenViewModel.Context?
|
||||
var updateClosure: (() -> Void)?
|
||||
|
||||
// This prevents the magnifying glass from showing up
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
gestureRecognizer as? UILongPressGestureRecognizer == nil
|
||||
}
|
||||
|
||||
func invalidateTextAttachmentsDisplay(update: Bool) {
|
||||
attributedText.enumerateAttribute(.attachment,
|
||||
in: NSRange(location: 0, length: attributedText.length),
|
||||
options: []) { value, range, _ in
|
||||
guard value != nil else {
|
||||
return
|
||||
}
|
||||
self.layoutManager.invalidateDisplay(forCharacterRange: range)
|
||||
if update {
|
||||
updateClosure?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Required to setup the first rendering of the pill view
|
||||
override func layoutSubviews() {
|
||||
invalidateTextAttachmentsDisplay(update: false)
|
||||
super.layoutSubviews()
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageText: UIViewRepresentable {
|
||||
@Environment(\.openURL) private var openURLAction: OpenURLAction
|
||||
let attributedString: AttributedString
|
||||
@EnvironmentObject private var viewModel: RoomScreenViewModel.Context
|
||||
@State var attributedString: AttributedString
|
||||
|
||||
func makeUIView(context: Context) -> MessageTextView {
|
||||
let textView = MessageTextView()
|
||||
// Need to use TextKit 1 for mentions
|
||||
let textView = MessageTextView(usingTextLayoutManager: false)
|
||||
textView.roomContext = viewModel
|
||||
textView.updateClosure = {
|
||||
attributedString = AttributedString(textView.attributedText)
|
||||
}
|
||||
textView.isEditable = false
|
||||
textView.isScrollEnabled = false
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
@@ -46,7 +74,7 @@ struct MessageText: UIViewRepresentable {
|
||||
textView.contentInsetAdjustmentBehavior = .never
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
textView.textLayoutManager?.usesFontLeading = false
|
||||
textView.layoutManager.usesFontLeading = false
|
||||
textView.backgroundColor = .clear
|
||||
textView.attributedText = NSAttributedString(attributedString)
|
||||
textView.delegate = context.coordinator
|
||||
@@ -94,9 +122,20 @@ struct MessageText_Previews: PreviewProvider, TestablePreview {
|
||||
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 attributedStringWithAttachment: AttributedString = {
|
||||
let testData = PillTextAttachmentData(type: .user(userID: "@alice:example.com"), font: .preferredFont(forTextStyle: .body))
|
||||
guard let attachment = PillTextAttachment(attachmentData: testData) else {
|
||||
return AttributedString()
|
||||
}
|
||||
|
||||
var attributedString = "Hello test test test " + AttributedString(NSAttributedString(attachment: attachment)) + " World!"
|
||||
attributedString
|
||||
.mergeAttributes(defaultFontContainer)
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
private static let htmlStringWithQuote =
|
||||
"""
|
||||
@@ -106,28 +145,36 @@ struct MessageText_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
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)
|
||||
private static let attributedStringBuilder = AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: true))
|
||||
|
||||
static var attachmentPreview: some View {
|
||||
MessageText(attributedString: attributedStringWithAttachment)
|
||||
.border(Color.purple)
|
||||
.environmentObject(RoomScreenViewModel.mock.context)
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
MessageText(attributedString: attributedString)
|
||||
.border(Color.purple)
|
||||
.previewDisplayName("Custom Text")
|
||||
.environmentObject(RoomScreenViewModel.mock.context)
|
||||
// For comparison
|
||||
Text(attributedString)
|
||||
.border(Color.purple)
|
||||
.previewDisplayName("SwiftUI Default Text")
|
||||
MessageText(attributedString: attributedStringWithAttachment)
|
||||
.border(Color.purple)
|
||||
attachmentPreview
|
||||
.previewDisplayName("Custom Attachment")
|
||||
if let attributedString = attributedStringBuilder.fromHTML(htmlStringWithQuote) {
|
||||
MessageText(attributedString: attributedString)
|
||||
.border(Color.purple)
|
||||
.previewDisplayName("With block quote")
|
||||
.environmentObject(RoomScreenViewModel.mock.context)
|
||||
}
|
||||
if let attributedString = attributedStringBuilder.fromHTML(htmlStringWithList) {
|
||||
MessageText(attributedString: attributedString)
|
||||
.border(Color.purple)
|
||||
.previewDisplayName("With list")
|
||||
.environmentObject(RoomScreenViewModel.mock.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,21 +15,52 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftUIIntrospect
|
||||
import UIKit
|
||||
|
||||
final class PillAttachmentViewProvider: NSTextAttachmentViewProvider {
|
||||
private weak var messageTextView: MessageTextView?
|
||||
|
||||
// MARK: - Override
|
||||
|
||||
override init(textAttachment: NSTextAttachment, parentView: UIView?, textLayoutManager: NSTextLayoutManager?, location: NSTextLocation) {
|
||||
super.init(textAttachment: textAttachment, parentView: parentView, textLayoutManager: textLayoutManager, location: location)
|
||||
|
||||
// Keep a reference to the parent text view for size adjustments and pills flushing.
|
||||
messageTextView = parentView?.superview as? MessageTextView
|
||||
tracksTextAttachmentViewBounds = true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
override func loadView() {
|
||||
super.loadView()
|
||||
|
||||
guard textAttachment is PillTextAttachment else {
|
||||
MXLog.failure("[PillAttachmentViewProvider]: attachment is missing or not of expected class")
|
||||
guard let textAttachmentData = (textAttachment as? PillTextAttachment)?.pillData else {
|
||||
MXLog.failure("[PillAttachmentViewProvider]: attachment is missing data or not of expected class")
|
||||
return
|
||||
}
|
||||
|
||||
let view = PillView()
|
||||
|
||||
let viewModel: PillContext
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
if ProcessInfo.isXcodePreview || ProcessInfo.isRunningTests {
|
||||
// The mock viewModel simulates the loading logic for testing purposes
|
||||
viewModel = PillContext.mock(type: .loadUser)
|
||||
imageProvider = MockMediaProvider()
|
||||
} else if let roomContext = messageTextView?.roomContext {
|
||||
viewModel = PillContext(roomContext: roomContext, data: textAttachmentData)
|
||||
imageProvider = roomContext.imageProvider
|
||||
} else {
|
||||
MXLog.failure("[PillAttachmentViewProvider]: missing room context")
|
||||
return
|
||||
}
|
||||
|
||||
let view = PillView(imageProvider: imageProvider, viewModel: viewModel) { [weak self] in
|
||||
self?.messageTextView?.invalidateTextAttachmentsDisplay(update: true)
|
||||
}
|
||||
let controller = UIHostingController(rootView: view)
|
||||
controller.view.backgroundColor = .clear
|
||||
// This allows the text view to handle it as a link
|
||||
controller.view.isUserInteractionEnabled = false
|
||||
self.view = controller.view
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class PillContext: ObservableObject {
|
||||
enum PillViewState {
|
||||
case loading(contentID: String)
|
||||
case loaded(contentID: String, name: String, avatarURL: URL?)
|
||||
}
|
||||
|
||||
@Published private var state: PillViewState
|
||||
|
||||
var url: URL? {
|
||||
switch state {
|
||||
case .loading:
|
||||
return nil
|
||||
case .loaded(_, _, let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
var name: String? {
|
||||
switch state {
|
||||
case .loading:
|
||||
return nil
|
||||
case .loaded(_, let name, _):
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
var displayText: String {
|
||||
switch state {
|
||||
case .loaded(_, let name, _):
|
||||
return name
|
||||
case .loading(let contentID):
|
||||
return contentID
|
||||
}
|
||||
}
|
||||
|
||||
var contentID: String {
|
||||
switch state {
|
||||
case .loaded(let contentID, _, _), .loading(let contentID):
|
||||
return contentID
|
||||
}
|
||||
}
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(roomContext: RoomScreenViewModel.Context, data: PillTextAttachmentData) {
|
||||
switch data.type {
|
||||
case let .user(id):
|
||||
if let profile = roomContext.viewState.members[id] {
|
||||
state = .loaded(contentID: id, name: profile.displayName ?? id, avatarURL: profile.avatarURL)
|
||||
} else {
|
||||
state = .loading(contentID: id)
|
||||
cancellable = roomContext.$viewState.sink { [weak self] viewState in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if let profile = viewState.members[id] {
|
||||
state = .loaded(contentID: id, name: profile.displayName ?? id, avatarURL: profile.avatarURL)
|
||||
cancellable = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PillContext {
|
||||
enum MockType {
|
||||
case loadUser
|
||||
case loadedUser
|
||||
}
|
||||
|
||||
static func mock(type: MockType) -> PillContext {
|
||||
let pillType: PillType
|
||||
switch type {
|
||||
case .loadUser:
|
||||
pillType = .user(userID: "@test:test.com")
|
||||
let viewModel = PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
viewModel.state = .loaded(contentID: "@test:test.com", name: "Test Longer Display Text", avatarURL: URL.documentsDirectory)
|
||||
}
|
||||
return viewModel
|
||||
case .loadedUser:
|
||||
pillType = .user(userID: "@test:test.com")
|
||||
let viewModel = PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
|
||||
viewModel.state = .loaded(contentID: "@test:test.com", name: "Very Very Long Test Display Text", avatarURL: URL.documentsDirectory)
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,27 @@
|
||||
import UIKit
|
||||
|
||||
/// Text attachment for pills display.
|
||||
final class PillTextAttachment: NSTextAttachment { }
|
||||
final class PillTextAttachment: NSTextAttachment {
|
||||
convenience init?(attachmentData: PillTextAttachmentData) {
|
||||
let encoder = JSONEncoder()
|
||||
guard let encodedData = try? encoder.encode(attachmentData) else { return nil }
|
||||
self.init(data: encodedData, ofType: InfoPlistReader.main.pillsUTType)
|
||||
}
|
||||
|
||||
var pillData: PillTextAttachmentData? {
|
||||
guard let contents else {
|
||||
return nil
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
return try? decoder.decode(PillTextAttachmentData.self, from: contents)
|
||||
}
|
||||
|
||||
override func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect {
|
||||
var rect = super.attachmentBounds(for: textContainer, proposedLineFragment: lineFrag, glyphPosition: position, characterIndex: charIndex)
|
||||
if let font = pillData?.font {
|
||||
// Align the pill text vertically with the surrounding text.
|
||||
rect.origin.y = font.descender + (font.lineHeight - rect.height) / 2.0
|
||||
}
|
||||
return rect
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import UIKit
|
||||
|
||||
enum PillType: Codable {
|
||||
/// A pill that mentions a user
|
||||
case user(userID: String)
|
||||
}
|
||||
|
||||
struct PillTextAttachmentData {
|
||||
// MARK: - Properties
|
||||
|
||||
/// Pill type
|
||||
let type: PillType
|
||||
|
||||
/// Font for the display name
|
||||
let font: UIFont
|
||||
}
|
||||
|
||||
extension PillTextAttachmentData: Codable {
|
||||
// MARK: - Codable
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case font
|
||||
}
|
||||
|
||||
enum PillTextAttachmentDataError: Error {
|
||||
case noFontData
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
type = try container.decode(PillType.self, forKey: .type)
|
||||
let fontData = try container.decode(Data.self, forKey: .font)
|
||||
if let font = try NSKeyedUnarchiver.unarchivedObject(ofClass: UIFont.self, from: fontData) {
|
||||
self.font = font
|
||||
} else {
|
||||
throw PillTextAttachmentDataError.noFontData
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(type, forKey: .type)
|
||||
let fontData = try NSKeyedArchiver.archivedData(withRootObject: font, requiringSecureCoding: false)
|
||||
try container.encode(fontData, forKey: .font)
|
||||
}
|
||||
}
|
||||
@@ -17,21 +17,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PillView: View {
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
@ObservedObject var viewModel: PillContext
|
||||
/// callback triggerd by changes in the display text
|
||||
let didChangeText: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
MXLog.info("TEXT ATTACHMENT TEST")
|
||||
} label: {
|
||||
HStack {
|
||||
Image(asset: Asset.Images.appLogo)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
LoadableAvatarImage(url: viewModel.url, name: viewModel.name, contentID: viewModel.contentID, avatarSize: .custom(24), imageProvider: imageProvider)
|
||||
Text(viewModel.displayText)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.foregroundColor(.compound.textOnSolidPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
// for now design has defined no color so we will just use gray
|
||||
.background(Capsule().foregroundColor(.gray))
|
||||
.frame(maxWidth: 235)
|
||||
.onChange(of: viewModel.displayText) { _ in
|
||||
didChangeText()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PillView_Previews: PreviewProvider, TestablePreview {
|
||||
static let mockMediaProvider = MockMediaProvider()
|
||||
|
||||
static var loading: some View {
|
||||
PillView(imageProvider: mockMediaProvider,
|
||||
viewModel: PillContext.mock(type: .loadUser)) { }
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
PillView()
|
||||
loading
|
||||
.previewDisplayName("Loading")
|
||||
PillView(imageProvider: mockMediaProvider,
|
||||
viewModel: PillContext.mock(type: .loadedUser)) { }
|
||||
.previewDisplayName("Loaded Long")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user