mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
@room pill (#1834)
* all users mention pill + red higlight for own mentions * more tests * changelog * removed useless if let
This commit is contained in:
@@ -46,4 +46,28 @@ struct MentionBuilder: MentionBuilderProtocol {
|
||||
attachmentString.addAttributes(attributes, range: NSRange(location: 0, length: attachmentString.length))
|
||||
attributedString.replaceCharacters(in: range, with: attachmentString)
|
||||
}
|
||||
|
||||
func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) {
|
||||
guard mentionsEnabled else {
|
||||
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: .allUsers, font: font)
|
||||
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
|
||||
return
|
||||
}
|
||||
|
||||
var attributesToAdd: [NSAttributedString.Key: Any] = [:]
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,21 +40,21 @@ final class PillAttachmentViewProvider: NSTextAttachmentViewProvider {
|
||||
return
|
||||
}
|
||||
|
||||
let viewModel: PillContext
|
||||
let context: PillContext
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
if ProcessInfo.isXcodePreview || ProcessInfo.isRunningTests {
|
||||
// The mock viewModel simulates the loading logic for testing purposes
|
||||
viewModel = PillContext.mock(type: .loadUser)
|
||||
context = PillContext.mock(type: .loadUser(isOwn: false))
|
||||
imageProvider = MockMediaProvider()
|
||||
} else if let roomContext = messageTextView?.roomContext {
|
||||
viewModel = PillContext(roomContext: roomContext, data: textAttachmentData)
|
||||
context = 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
|
||||
let view = PillView(imageProvider: imageProvider, context: context) { [weak self] in
|
||||
self?.messageTextView?.invalidateTextAttachmentsDisplay(update: true)
|
||||
}
|
||||
let controller = UIHostingController(rootView: view)
|
||||
|
||||
@@ -19,92 +19,72 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
final class PillContext: ObservableObject {
|
||||
enum PillViewState {
|
||||
case loading(contentID: String)
|
||||
case loaded(contentID: String, name: String, avatarURL: URL?)
|
||||
struct PillViewState: Equatable {
|
||||
let contentID: String
|
||||
let isOwnMention: Bool
|
||||
let name: String?
|
||||
let displayText: String
|
||||
let 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
|
||||
}
|
||||
}
|
||||
@Published private(set) var viewState: PillViewState
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(roomContext: RoomScreenViewModel.Context, data: PillTextAttachmentData) {
|
||||
switch data.type {
|
||||
case let .user(id):
|
||||
let isOwnMention = id == roomContext.viewState.ownUserID
|
||||
if let profile = roomContext.viewState.members[id] {
|
||||
state = .loaded(contentID: id, name: profile.displayName ?? id, avatarURL: profile.avatarURL)
|
||||
let name = profile.displayName ?? id
|
||||
viewState = PillViewState(contentID: id, isOwnMention: isOwnMention, name: name, displayText: name, avatarURL: profile.avatarURL)
|
||||
} else {
|
||||
state = .loading(contentID: id)
|
||||
viewState = PillViewState(contentID: id, isOwnMention: isOwnMention, name: nil, displayText: id, avatarURL: nil)
|
||||
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)
|
||||
let name = profile.displayName ?? id
|
||||
self.viewState = PillViewState(contentID: id, isOwnMention: isOwnMention, name: name, displayText: name, avatarURL: profile.avatarURL)
|
||||
cancellable = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
case .allUsers:
|
||||
viewState = PillViewState(contentID: roomContext.viewState.roomID, isOwnMention: true, name: roomContext.viewState.roomTitle, displayText: "@room", avatarURL: roomContext.viewState.roomAvatarURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PillContext {
|
||||
enum MockType {
|
||||
case loadUser
|
||||
case loadedUser
|
||||
case loadUser(isOwn: Bool)
|
||||
case loadedUser(isOwn: Bool)
|
||||
case allUsers
|
||||
}
|
||||
|
||||
static func mock(type: MockType) -> PillContext {
|
||||
let testID = "@test:test.com"
|
||||
let pillType: PillType
|
||||
switch type {
|
||||
case .loadUser:
|
||||
pillType = .user(userID: "@test:test.com")
|
||||
case .loadUser(let isOwn):
|
||||
pillType = .user(userID: testID)
|
||||
let viewModel = PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
|
||||
viewModel.viewState = PillViewState(contentID: testID, isOwnMention: isOwn, name: nil, displayText: testID, avatarURL: nil)
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
viewModel.state = .loaded(contentID: "@test:test.com", name: "Test Longer Display Text", avatarURL: URL.documentsDirectory)
|
||||
viewModel.viewState = PillViewState(contentID: "@test:test.com", isOwnMention: isOwn, name: nil, displayText: "Test Long Display Text", avatarURL: URL.documentsDirectory)
|
||||
}
|
||||
return viewModel
|
||||
case .loadedUser:
|
||||
case .loadedUser(let isOwn):
|
||||
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)
|
||||
viewModel.viewState = PillViewState(contentID: "@test:test.com", isOwnMention: isOwn, name: nil, displayText: "Very Very Long Test Display Text", avatarURL: URL.documentsDirectory)
|
||||
return viewModel
|
||||
case .allUsers:
|
||||
pillType = .allUsers
|
||||
return PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import UIKit
|
||||
enum PillType: Codable {
|
||||
/// A pill that mentions a user
|
||||
case user(userID: String)
|
||||
/// A pill that mentions all users in a room
|
||||
case allUsers
|
||||
}
|
||||
|
||||
struct PillTextAttachmentData {
|
||||
|
||||
@@ -18,14 +18,14 @@ import SwiftUI
|
||||
|
||||
struct PillView: View {
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
@ObservedObject var viewModel: PillContext
|
||||
@ObservedObject var context: PillContext
|
||||
/// callback triggerd by changes in the display text
|
||||
let didChangeText: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
LoadableAvatarImage(url: viewModel.url, name: viewModel.name, contentID: viewModel.contentID, avatarSize: .custom(24), imageProvider: imageProvider)
|
||||
Text(viewModel.displayText)
|
||||
LoadableAvatarImage(url: context.viewState.avatarURL, name: context.viewState.name, contentID: context.viewState.contentID, avatarSize: .custom(24), imageProvider: imageProvider)
|
||||
Text(context.viewState.displayText)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.foregroundColor(.compound.textOnSolidPrimary)
|
||||
.lineLimit(1)
|
||||
@@ -33,9 +33,9 @@ struct PillView: View {
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
// for now design has defined no color so we will just use gray
|
||||
.background(Capsule().foregroundColor(.gray))
|
||||
.background(Capsule().foregroundColor(context.viewState.isOwnMention ? .compound.bgCriticalPrimary : .gray))
|
||||
.frame(maxWidth: 235)
|
||||
.onChange(of: viewModel.displayText) { _ in
|
||||
.onChange(of: context.viewState.displayText) { _ in
|
||||
didChangeText()
|
||||
}
|
||||
}
|
||||
@@ -44,16 +44,21 @@ struct PillView: View {
|
||||
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 {
|
||||
loading
|
||||
PillView(imageProvider: mockMediaProvider,
|
||||
context: PillContext.mock(type: .loadUser(isOwn: false))) { }
|
||||
.previewDisplayName("Loading")
|
||||
PillView(imageProvider: mockMediaProvider,
|
||||
viewModel: PillContext.mock(type: .loadedUser)) { }
|
||||
context: PillContext.mock(type: .loadUser(isOwn: true))) { }
|
||||
.previewDisplayName("Loading Own")
|
||||
PillView(imageProvider: mockMediaProvider,
|
||||
context: PillContext.mock(type: .loadedUser(isOwn: false))) { }
|
||||
.previewDisplayName("Loaded Long")
|
||||
PillView(imageProvider: mockMediaProvider,
|
||||
context: PillContext.mock(type: .loadedUser(isOwn: true))) { }
|
||||
.previewDisplayName("Loaded Long Own")
|
||||
PillView(imageProvider: mockMediaProvider,
|
||||
context: PillContext.mock(type: .allUsers)) { }
|
||||
.previewDisplayName("All Users")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user