@room pill (#1834)

* all users mention pill + red higlight for own mentions

* more tests

* changelog

* removed useless if let
This commit is contained in:
Mauro
2023-09-29 15:40:31 +02:00
committed by GitHub
parent 1e842fd8cf
commit b7c46ceac3
19 changed files with 326 additions and 98 deletions
@@ -143,8 +143,9 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
if let value = value as? UIColor,
value == temporaryCodeBlockMarkingColor {
attributedString.addAttribute(.backgroundColor, value: UIColor(.compound._bgCodeBlock) as Any, range: range)
// Codeblocks should not have links
// Codeblocks should not have links and all users mentions
attributedString.removeAttribute(.link, range: range)
attributedString.removeAttribute(.MatrixAllUsersMention, range: range)
}
}
}
@@ -173,36 +174,40 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
let linkMatches = MatrixEntityRegex.linkRegex.matches(in: string, options: [])
matches.append(contentsOf: linkMatches)
guard matches.count > 0 else {
return
}
// Sort the links by length so the longest one always takes priority
matches.sorted { $0.range.length > $1.range.length }.forEach { match in
guard let matchRange = Range(match.range, in: string) else {
return
}
var hasLink = false
attributedString.enumerateAttribute(.link, in: match.range, options: []) { value, _, stop in
if value != nil {
hasLink = true
stop.pointee = true
if matches.count > 0 {
// Sort the links by length so the longest one always takes priority
matches.sorted { $0.range.length > $1.range.length }.forEach { match in
guard let matchRange = Range(match.range, in: string) else {
return
}
var hasLink = false
attributedString.enumerateAttribute(.link, in: match.range, options: []) { value, _, stop in
if value != nil {
hasLink = true
stop.pointee = true
}
}
if hasLink {
return
}
var link = String(string[matchRange])
if linkMatches.contains(match), !link.contains("://") {
link.insert(contentsOf: "https://", at: link.startIndex)
}
if let url = URL(string: link) {
attributedString.addAttribute(.link, value: url, range: match.range)
}
}
if hasLink {
return
}
var link = String(string[matchRange])
if linkMatches.contains(match), !link.contains("://") {
link.insert(contentsOf: "https://", at: link.startIndex)
}
if let url = URL(string: link) {
attributedString.addAttribute(.link, value: url, range: match.range)
}
MatrixEntityRegex.allUsersRegex.matches(in: string, options: []).forEach { match in
if attributedString.attribute(.link, at: 0, longestEffectiveRange: nil, in: match.range) == nil {
attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range)
}
}
}
@@ -226,6 +231,13 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
}
}
}
attributedString.enumerateAttribute(.MatrixAllUsersMention, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
if let value = value as? Bool,
value {
mentionBuilder.handleAllUsersMention(for: attributedString, in: range)
}
}
}
private func removeDefaultForegroundColor(_ attributedString: NSMutableAttributedString) {
@@ -286,8 +298,10 @@ extension NSAttributedString.Key {
static let MatrixRoomID: NSAttributedString.Key = .init(rawValue: RoomIDAttribute.name)
static let MatrixRoomAlias: NSAttributedString.Key = .init(rawValue: RoomAliasAttribute.name)
static let MatrixEventID: NSAttributedString.Key = .init(rawValue: EventIDAttribute.name)
static let MatrixAllUsersMention: NSAttributedString.Key = .init(rawValue: AllUsersMentionAttribute.name)
}
protocol MentionBuilderProtocol {
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String)
func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange)
}
@@ -36,6 +36,11 @@ enum RoomAliasAttribute: AttributedStringKey {
public static var name = "MXRoomAliasAttribute"
}
enum AllUsersMentionAttribute: AttributedStringKey {
typealias Value = Bool
public static var name = "MXAllUsersMentionAttribute"
}
struct EventIDAttributeValue: Hashable {
let roomID: String
let eventID: String
@@ -55,6 +60,8 @@ extension AttributeScopes {
let roomAlias: RoomAliasAttribute
let eventID: EventIDAttribute
let allUsersMention: AllUsersMentionAttribute
let swiftUI: SwiftUIAttributes
let uiKit: UIKitAttributes
}
@@ -23,6 +23,7 @@ enum MatrixEntityRegex: String {
case roomAlias
case roomId
case eventId
case allUsers
var rawValue: String {
switch self {
@@ -36,6 +37,8 @@ enum MatrixEntityRegex: String {
return "![A-Z0-9]+:" + MatrixEntityRegex.homeserver.rawValue
case .eventId:
return "\\$[a-z0-9_\\-\\/]+(:[a-z0-9]+\\.[a-z0-9]+)?"
case .allUsers:
return "@room"
}
}
@@ -45,6 +48,7 @@ enum MatrixEntityRegex: String {
static var roomAliasRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.roomAlias.rawValue, options: .caseInsensitive)
static var roomIdentifierRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.roomId.rawValue, options: .caseInsensitive)
static var eventIdentifierRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.eventId.rawValue, options: .caseInsensitive)
static var allUsersRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.allUsers.rawValue)
static var linkRegex = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
// swiftlint:enable force_try
@@ -87,4 +91,12 @@ enum MatrixEntityRegex: String {
return match.range.length == identifier.count
}
static func containsMatrixAllUsers(_ string: String) -> Bool {
guard allUsersRegex.firstMatch(in: string) != nil else {
return false
}
return true
}
}
@@ -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)
+28 -48
View File
@@ -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 {
+17 -12
View File
@@ -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")
}
}
@@ -102,6 +102,7 @@ struct RoomScreenViewState: BindableState {
var timelineViewState = TimelineViewState() // check the doc before changing this
var swiftUITimelineEnabled = false
var longPressDisabledItemID: TimelineItemIdentifier?
var ownUserID: String
var bindings: RoomScreenViewStateBindings
@@ -62,6 +62,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
timelineStyle: appSettings.timelineStyle,
readReceiptsEnabled: appSettings.readReceiptsEnabled,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
ownUserID: roomProxy.ownUserID,
bindings: .init(reactionsCollapsed: [:])),
imageProvider: mediaProvider)