Render Room and Message Pills (#3809)

* added a way to render the room and the message

pills, but is WIP

* permalinks now get converted into pills!

* fixed an issue where room address mentions

were not adding a URL properly but a string

* updated tests

* c

* Revert "c"

This reverts commit 5c80252fa23dba7e4d44f2a07fbf1e9500e37c82.

* updated tests

* more tests

* created APIs to get a specific RoomSummary

given the id or the alias

* small mention builder improvement

* pr suggestions
This commit is contained in:
Mauro
2025-02-25 14:46:01 +01:00
committed by GitHub
parent 944fe37fde
commit a2242c63e2
100 changed files with 1017 additions and 247 deletions
+149 -25
View File
@@ -9,13 +9,20 @@ import Foundation
import UIKit
struct MentionBuilder: MentionBuilderProtocol {
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?) {
let attributes = attributedString.attributes(at: 0, longestEffectiveRange: nil, in: range)
let font = attributes[.font] as? UIFont ?? .preferredFont(forTextStyle: .body)
let blockquote = attributes[.MatrixBlockquote]
let foregroundColor = attributes[.foregroundColor] as? UIColor ?? .compound.textPrimary
struct AttributesToRestore {
let font: UIFont
let blockquote: Bool?
let foregroundColor: UIColor
}
func handleUserMention(for attributedString: NSMutableAttributedString,
in range: NSRange,
url: URL,
userID: String,
userDisplayName: String?) {
let attributesToRestore = getAttributesToRestore(for: attributedString, in: range)
let attachmentData = PillTextAttachmentData(type: .user(userID: userID), font: font)
let attachmentData = PillTextAttachmentData(type: .user(userID: userID), font: attributesToRestore.font)
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
attributedString.addAttribute(.MatrixUserID, value: userID, range: range)
@@ -26,34 +33,151 @@ struct MentionBuilder: MentionBuilderProtocol {
return
}
var attachmentAttributes: [NSAttributedString.Key: Any] = [.link: url, .MatrixUserID: userID, .font: font, .foregroundColor: foregroundColor]
if let blockquote {
// mentions can be in blockquotes, so if the replaced string was in one, we keep the attribute
attachmentAttributes[.MatrixBlockquote] = blockquote
}
let attachmentString = NSMutableAttributedString(attachment: attachment)
attachmentString.addAttributes(attachmentAttributes, range: NSRange(location: 0, length: attachmentString.length))
attributedString.replaceCharacters(in: range, with: attachmentString)
var attachmentAttributes: [NSAttributedString.Key: Any] = [.link: url,
.MatrixUserID: userID,
.font: attributesToRestore.font,
.foregroundColor: attributesToRestore.foregroundColor]
attachmentAttributes.addBlockquoteIfNeeded(attributesToRestore.blockquote)
attachmentAttributes.addMatrixUsernameIfNeeded(userDisplayName)
setPillAttachment(attachment: attachment,
attributedString: attributedString,
in: range,
with: attachmentAttributes)
}
func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) {
let attributes = attributedString.attributes(at: 0, longestEffectiveRange: nil, in: range)
let font = attributes[.font] as? UIFont ?? .preferredFont(forTextStyle: .body)
let blockquote = attributes[.MatrixBlockquote]
let foregroundColor = attributes[.foregroundColor] as? UIColor ?? .compound.textPrimary
let attachmentData = PillTextAttachmentData(type: .allUsers, font: font)
let attributesToRestore = getAttributesToRestore(for: attributedString, in: range)
let attachmentData = PillTextAttachmentData(type: .allUsers, font: attributesToRestore.font)
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
return
}
var attachmentAttributes: [NSAttributedString.Key: Any] = [.font: font, .MatrixAllUsersMention: true, .foregroundColor: foregroundColor]
if let blockquote {
// mentions can be in blockquotes, so if the replaced string was in one, we keep the attribute
attachmentAttributes[.MatrixBlockquote] = blockquote
var attachmentAttributes: [NSAttributedString.Key: Any] = [.font: attributesToRestore.font,
.MatrixAllUsersMention: true,
.foregroundColor: attributesToRestore.foregroundColor]
attachmentAttributes.addBlockquoteIfNeeded(attributesToRestore.blockquote)
setPillAttachment(attachment: attachment,
attributedString: attributedString,
in: range,
with: attachmentAttributes)
}
func handleRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomID: String) {
let attributesToRestore = getAttributesToRestore(for: attributedString, in: range)
let attachmentData = PillTextAttachmentData(type: .roomID(roomID), font: attributesToRestore.font)
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
attributedString.addAttribute(.MatrixRoomID, value: roomID, range: range)
return
}
var attachmentAttributes: [NSAttributedString.Key: Any] = [.link: url,
.MatrixRoomID: roomID,
.font: attributesToRestore.font,
.foregroundColor: attributesToRestore.foregroundColor]
attachmentAttributes.addBlockquoteIfNeeded(attributesToRestore.blockquote)
setPillAttachment(attachment: attachment,
attributedString: attributedString,
in: range,
with: attachmentAttributes)
}
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String) {
let attributesToRestore = getAttributesToRestore(for: attributedString, in: range)
let attachmentData = PillTextAttachmentData(type: .roomAlias(roomAlias), font: attributesToRestore.font)
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
attributedString.addAttribute(.MatrixRoomAlias, value: roomAlias, range: range)
return
}
var attachmentAttributes: [NSAttributedString.Key: Any] = [.link: url,
.MatrixRoomAlias: roomAlias,
.font: attributesToRestore.font,
.foregroundColor: attributesToRestore.foregroundColor]
attachmentAttributes.addBlockquoteIfNeeded(attributesToRestore.blockquote)
setPillAttachment(attachment: attachment,
attributedString: attributedString,
in: range,
with: attachmentAttributes)
}
func handleEventOnRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomAlias: String) {
let attributesToRestore = getAttributesToRestore(for: attributedString, in: range)
let attachmentData = PillTextAttachmentData(type: .event(room: .roomAlias(roomAlias)), font: attributesToRestore.font)
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
attributedString.addAttribute(.MatrixEventOnRoomAlias, value: EventOnRoomAliasAttribute.Value(alias: roomAlias, eventID: eventID), range: range)
return
}
var attachmentAttributes: [NSAttributedString.Key: Any] = [.link: url,
.MatrixEventOnRoomAlias: EventOnRoomAliasAttribute.Value(alias: roomAlias, eventID: eventID),
.font: attributesToRestore.font,
.foregroundColor: attributesToRestore.foregroundColor]
attachmentAttributes.addBlockquoteIfNeeded(attributesToRestore.blockquote)
setPillAttachment(attachment: attachment,
attributedString: attributedString,
in: range,
with: attachmentAttributes)
}
func handleEventOnRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomID: String) {
let attributesToRestore = getAttributesToRestore(for: attributedString, in: range)
let attachmentData = PillTextAttachmentData(type: .event(room: .roomID(roomID)), font: attributesToRestore.font)
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
attributedString.addAttribute(.MatrixEventOnRoomID, value: EventOnRoomIDAttribute.Value(roomID: roomID, eventID: eventID), range: range)
return
}
var attachmentAttributes: [NSAttributedString.Key: Any] = [.link: url,
.MatrixEventOnRoomID: EventOnRoomIDAttribute.Value(roomID: roomID, eventID: eventID),
.font: attributesToRestore.font,
.foregroundColor: attributesToRestore.foregroundColor]
attachmentAttributes.addBlockquoteIfNeeded(attributesToRestore.blockquote)
setPillAttachment(attachment: attachment,
attributedString: attributedString,
in: range,
with: attachmentAttributes)
}
private func getAttributesToRestore(for attributedString: NSMutableAttributedString, in range: NSRange) -> AttributesToRestore {
let attributes = attributedString.attributes(at: 0, longestEffectiveRange: nil, in: range)
let font = attributes[.font] as? UIFont ?? .preferredFont(forTextStyle: .body)
let blockquote = attributes[.MatrixBlockquote] as? Bool
let foregroundColor = attributes[.foregroundColor] as? UIColor ?? .compound.textPrimary
return AttributesToRestore(font: font, blockquote: blockquote, foregroundColor: foregroundColor)
}
private func setPillAttachment(attachment: PillTextAttachment,
attributedString: NSMutableAttributedString,
in range: NSRange,
with attributes: [NSAttributedString.Key: Any]) {
let attachmentString = NSMutableAttributedString(attachment: attachment)
attachmentString.addAttributes(attachmentAttributes, range: NSRange(location: 0, length: attachmentString.length))
attachmentString.addAttributes(attributes, range: NSRange(location: 0, length: attachmentString.length))
attributedString.replaceCharacters(in: range, with: attachmentString)
}
}
private extension Dictionary where Key == NSAttributedString.Key, Value == Any {
mutating func addBlockquoteIfNeeded(_ value: Bool?) {
if let value {
self[.MatrixBlockquote] = value
}
}
mutating func addMatrixUsernameIfNeeded(_ value: String?) {
if let value {
self[.MatrixUserDisplayName] = value
}
}
}
@@ -45,7 +45,7 @@ final class PillAttachmentViewProvider: NSTextAttachmentViewProvider, NSSecureCo
let mediaProvider: MediaProviderProtocol?
if ProcessInfo.isXcodePreview || ProcessInfo.isRunningTests {
// The mock viewModel simulates the loading logic for testing purposes
context = PillContext.mock(type: .loadUser(isOwn: false))
context = PillContext.mock(viewState: .mention(isOwnMention: false, displayText: "Alice"), delay: .seconds(2))
mediaProvider = MediaProviderMock(configuration: .init())
} else if let timelineContext = delegate?.timelineContext {
context = PillContext(timelineContext: timelineContext, data: pillData)
+62 -60
View File
@@ -10,75 +10,77 @@ import Foundation
@MainActor
final class PillContext: ObservableObject {
struct PillViewState: Equatable {
let isOwnMention: Bool
let displayText: String
}
@Published var viewState: PillViewState = .undefined
@Published private(set) var viewState: PillViewState
private var cancellable: AnyCancellable?
let data: PillTextAttachmentData
var cancellable: AnyCancellable?
init(timelineContext: TimelineViewModel.Context, data: PillTextAttachmentData) {
switch data.type {
case let .user(id):
let isOwnMention = id == timelineContext.viewState.ownUserID
if let profile = timelineContext.viewState.members[id] {
var name = id
if let displayName = profile.displayName {
name = "@\(displayName)"
}
viewState = PillViewState(isOwnMention: isOwnMention, displayText: name)
} else {
viewState = PillViewState(isOwnMention: isOwnMention, displayText: id)
cancellable = timelineContext.$viewState.sink { [weak self] viewState in
guard let self else {
return
}
if let profile = viewState.members[id] {
var name = id
if let displayName = profile.displayName {
name = "@\(displayName)"
}
self.viewState = PillViewState(isOwnMention: isOwnMention, displayText: name)
cancellable = nil
}
}
}
case .allUsers:
viewState = PillViewState(isOwnMention: true, displayText: PillConstants.atRoom)
}
self.data = data
timelineContext.viewState.pillContextUpdater?(self)
}
}
extension PillContext {
enum MockType {
case loadUser(isOwn: Bool)
case loadedUser(isOwn: Bool)
case allUsers
static func mock(viewState: PillViewState, delay: Duration? = nil) -> PillContext {
// This is just for previews so the internal data doesn't really matter
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body)))
if let delay {
viewModel.viewState = .mention(isOwnMention: false, displayText: "placeholder")
Task {
try? await Task.sleep(for: delay)
viewModel.viewState = viewState
}
} else {
viewModel.viewState = viewState
}
return viewModel
}
}
enum PillViewState: Equatable {
enum PillImage: Equatable {
case link
case roomAvatar(RoomAvatar)
}
static func mock(type: MockType) -> PillContext {
let testID = "@test:test.com"
let pillType: PillType
switch type {
case .loadUser(let isOwn):
pillType = .user(userID: testID)
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: testID)
Task {
try? await Task.sleep(for: .seconds(2))
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: "@Test Long Display Text")
}
return viewModel
case .loadedUser(let isOwn):
pillType = .user(userID: "@test:test.com")
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: "@Very Very Long Test Display Text")
return viewModel
case .allUsers:
pillType = .allUsers
return PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
case mention(isOwnMention: Bool, displayText: String)
case reference(avatar: PillImage, displayText: String)
case undefined
var isOwnMention: Bool {
switch self {
case .mention(let isOwnMention, _):
return isOwnMention
default:
return false
}
}
var displayText: String {
switch self {
case .mention(_, let displayText), .reference(_, let displayText):
return displayText
case .undefined:
return ""
}
}
var isUndefined: Bool {
switch self {
case .undefined:
return true
default:
return false
}
}
var image: PillImage? {
switch self {
case .reference(let avatar, _):
return avatar
default:
return nil
}
}
}
@@ -9,6 +9,21 @@ import Foundation
import UIKit
enum PillType: Codable, Equatable {
enum EventRoom: Codable, Equatable {
case roomAlias(String)
case roomID(String)
var value: String {
switch self {
case .roomAlias(let value), .roomID(let value):
return value
}
}
}
case event(room: EventRoom)
case roomAlias(String)
case roomID(String)
/// A pill that mentions a user
case user(userID: String)
/// A pill that mentions all users in a room
+46 -18
View File
@@ -5,6 +5,7 @@
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct PillView: View {
@@ -22,18 +23,42 @@ struct PillView: View {
}
var body: some View {
Text(context.viewState.displayText)
.font(.compound.bodyLGSemibold)
.foregroundColor(textColor)
.lineLimit(1)
.padding(.leading, 4)
.padding(.trailing, 6)
.padding(.vertical, 1)
.background { Capsule().foregroundColor(backgroundColor) }
mainContent
.onChange(of: context.viewState.displayText) {
didChangeText()
}
}
@ViewBuilder
private var mainContent: some View {
HStack(spacing: 4) {
image
Text(context.viewState.displayText)
.font(.compound.bodyLGSemibold)
.foregroundColor(textColor)
.lineLimit(1)
}
.padding(.leading, 4)
.padding(.trailing, 6)
.padding(.vertical, 1)
.background { Capsule().foregroundColor(backgroundColor) }
}
@ViewBuilder
private var image: some View {
if let image = context.viewState.image {
switch image {
case .link:
CompoundIcon(\.link, size: .custom(12), relativeTo: .compound.bodyLGSemibold)
.padding(2)
.foregroundStyle(.compound.bgCanvasDefault)
.background(.compound.textLinkExternal)
.clipShape(Circle())
case .roomAvatar(let avatar):
RoomAvatarImage(avatar: avatar, avatarSize: .custom(16), mediaProvider: mediaProvider)
}
}
}
}
struct PillView_Previews: PreviewProvider, TestablePreview {
@@ -41,24 +66,27 @@ struct PillView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
PillView(mediaProvider: mockMediaProvider,
context: PillContext.mock(type: .loadUser(isOwn: false))) { }
context: PillContext.mock(viewState: .mention(isOwnMention: false,
displayText: "@Alice"))) { }
.frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("Loading")
.previewDisplayName("User")
PillView(mediaProvider: mockMediaProvider,
context: PillContext.mock(type: .loadUser(isOwn: true))) { }
context: PillContext.mock(viewState: .mention(isOwnMention: false,
displayText: "@Alice but with a very very long name"))) { }
.frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("Loading Own")
.previewDisplayName("User with a long name")
PillView(mediaProvider: mockMediaProvider,
context: PillContext.mock(type: .loadedUser(isOwn: false))) { }
context: PillContext.mock(viewState: .mention(isOwnMention: true,
displayText: "@Alice"))) { }
.frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("Loaded Long")
.previewDisplayName("Own user")
PillView(mediaProvider: mockMediaProvider,
context: PillContext.mock(type: .loadedUser(isOwn: true))) { }
context: PillContext.mock(viewState: .reference(avatar: .roomAvatar(.room(id: "roomID", name: "Room", avatarURL: nil)), displayText: "Room"))) { }
.frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("Loaded Long Own")
.previewDisplayName("Room")
PillView(mediaProvider: mockMediaProvider,
context: PillContext.mock(type: .allUsers)) { }
context: PillContext.mock(viewState: .reference(avatar: .link, displayText: L10n.screenRoomEventPill("Room")))) { }
.frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("All Users")
.previewDisplayName("Message link")
}
}
@@ -9,6 +9,12 @@ import Foundation
// In the future we might use this to do some customisation in what is plain text used to represent mentions.
struct PlainMentionBuilder: MentionBuilderProtocol {
func handleEventOnRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomAlias: String) { }
func handleEventOnRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomID: String) { }
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String) { }
func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) { }
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?) {
@@ -17,4 +23,6 @@ struct PlainMentionBuilder: MentionBuilderProtocol {
}
attributedString.insert(NSAttributedString(string: "@"), at: range.location)
}
func handleRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomID: String) { }
}