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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
100 changed files with 1017 additions and 247 deletions

View File

@ -93,7 +93,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
appMediator: appMediator, appMediator: appMediator,
emojiProvider: emojiProvider, emojiProvider: emojiProvider,
userIndicatorController: userIndicatorController, userIndicatorController: userIndicatorController,
timelineControllerFactory: timelineControllerFactory) timelineControllerFactory: timelineControllerFactory,
clientProxy: userSession.clientProxy)
let coordinator = MediaEventsTimelineScreenCoordinator(parameters: parameters) let coordinator = MediaEventsTimelineScreenCoordinator(parameters: parameters)

View File

@ -78,7 +78,8 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
voiceMessageMediaManager: userSession.voiceMessageMediaManager, voiceMessageMediaManager: userSession.voiceMessageMediaManager,
appMediator: appMediator, appMediator: appMediator,
emojiProvider: emojiProvider, emojiProvider: emojiProvider,
timelineControllerFactory: timelineControllerFactory)) timelineControllerFactory: timelineControllerFactory,
clientProxy: userSession.clientProxy))
coordinator.actions coordinator.actions
.sink { [weak self] action in .sink { [weak self] action in

View File

@ -3225,6 +3225,146 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return roomPreviewForIdentifierViaReturnValue return roomPreviewForIdentifierViaReturnValue
} }
} }
//MARK: - roomSummaryForIdentifier
var roomSummaryForIdentifierUnderlyingCallsCount = 0
var roomSummaryForIdentifierCallsCount: Int {
get {
if Thread.isMainThread {
return roomSummaryForIdentifierUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = roomSummaryForIdentifierUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomSummaryForIdentifierUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
roomSummaryForIdentifierUnderlyingCallsCount = newValue
}
}
}
}
var roomSummaryForIdentifierCalled: Bool {
return roomSummaryForIdentifierCallsCount > 0
}
var roomSummaryForIdentifierReceivedIdentifier: String?
var roomSummaryForIdentifierReceivedInvocations: [String] = []
var roomSummaryForIdentifierUnderlyingReturnValue: RoomSummary?
var roomSummaryForIdentifierReturnValue: RoomSummary? {
get {
if Thread.isMainThread {
return roomSummaryForIdentifierUnderlyingReturnValue
} else {
var returnValue: RoomSummary?? = nil
DispatchQueue.main.sync {
returnValue = roomSummaryForIdentifierUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomSummaryForIdentifierUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
roomSummaryForIdentifierUnderlyingReturnValue = newValue
}
}
}
}
var roomSummaryForIdentifierClosure: ((String) -> RoomSummary?)?
func roomSummaryForIdentifier(_ identifier: String) -> RoomSummary? {
roomSummaryForIdentifierCallsCount += 1
roomSummaryForIdentifierReceivedIdentifier = identifier
DispatchQueue.main.async {
self.roomSummaryForIdentifierReceivedInvocations.append(identifier)
}
if let roomSummaryForIdentifierClosure = roomSummaryForIdentifierClosure {
return roomSummaryForIdentifierClosure(identifier)
} else {
return roomSummaryForIdentifierReturnValue
}
}
//MARK: - roomSummaryForAlias
var roomSummaryForAliasUnderlyingCallsCount = 0
var roomSummaryForAliasCallsCount: Int {
get {
if Thread.isMainThread {
return roomSummaryForAliasUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = roomSummaryForAliasUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomSummaryForAliasUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
roomSummaryForAliasUnderlyingCallsCount = newValue
}
}
}
}
var roomSummaryForAliasCalled: Bool {
return roomSummaryForAliasCallsCount > 0
}
var roomSummaryForAliasReceivedAlias: String?
var roomSummaryForAliasReceivedInvocations: [String] = []
var roomSummaryForAliasUnderlyingReturnValue: RoomSummary?
var roomSummaryForAliasReturnValue: RoomSummary? {
get {
if Thread.isMainThread {
return roomSummaryForAliasUnderlyingReturnValue
} else {
var returnValue: RoomSummary?? = nil
DispatchQueue.main.sync {
returnValue = roomSummaryForAliasUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomSummaryForAliasUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
roomSummaryForAliasUnderlyingReturnValue = newValue
}
}
}
}
var roomSummaryForAliasClosure: ((String) -> RoomSummary?)?
func roomSummaryForAlias(_ alias: String) -> RoomSummary? {
roomSummaryForAliasCallsCount += 1
roomSummaryForAliasReceivedAlias = alias
DispatchQueue.main.async {
self.roomSummaryForAliasReceivedInvocations.append(alias)
}
if let roomSummaryForAliasClosure = roomSummaryForAliasClosure {
return roomSummaryForAliasClosure(alias)
} else {
return roomSummaryForAliasReturnValue
}
}
//MARK: - loadUserDisplayName //MARK: - loadUserDisplayName
var loadUserDisplayNameUnderlyingCallsCount = 0 var loadUserDisplayNameUnderlyingCallsCount = 0

View File

@ -67,6 +67,31 @@ extension RoomSummaryProviderMock {
} }
} }
extension RoomSummary {
static func mock(id: String,
name: String,
canonicalAlias: String? = nil) -> RoomSummary {
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: id,
joinRequestType: nil,
name: name,
isDirect: false,
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("I do not wish to take the trouble to understand mysticism"),
lastMessageFormattedTimestamp: "14:56",
unreadMessagesCount: 0,
unreadMentionsCount: 0,
unreadNotificationsCount: 0,
notificationMode: .allMessages,
canonicalAlias: canonicalAlias,
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false)
}
}
extension Array where Element == RoomSummary { extension Array where Element == RoomSummary {
static let mockRooms: [Element] = [ static let mockRooms: [Element] = [
RoomSummary(roomListItem: RoomListItemSDKMock(), RoomSummary(roomListItem: RoomListItemSDKMock(),
@ -83,6 +108,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: .allMessages, notificationMode: .allMessages,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false), isFavourite: false),
@ -99,7 +125,8 @@ extension Array where Element == RoomSummary {
unreadMentionsCount: 0, unreadMentionsCount: 0,
unreadNotificationsCount: 2, unreadNotificationsCount: 2,
notificationMode: .mute, notificationMode: .mute,
canonicalAlias: nil, canonicalAlias: "#foundation-and-empire:matrix.org",
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false), isFavourite: false),
@ -117,6 +144,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: .mentionsAndKeywordsOnly, notificationMode: .mentionsAndKeywordsOnly,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false), isFavourite: false),
@ -134,6 +162,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 2, unreadNotificationsCount: 2,
notificationMode: .allMessages, notificationMode: .allMessages,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false), isFavourite: false),
@ -151,6 +180,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 1, unreadNotificationsCount: 1,
notificationMode: .allMessages, notificationMode: .allMessages,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: true, hasOngoingCall: true,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false), isFavourite: false),
@ -168,6 +198,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: .mute, notificationMode: .mute,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: true, hasOngoingCall: true,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false), isFavourite: false),
@ -185,6 +216,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: nil, notificationMode: nil,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false) isFavourite: false)
@ -235,6 +267,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: nil, notificationMode: nil,
canonicalAlias: "#footest:somewhere.org", canonicalAlias: "#footest:somewhere.org",
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false), isFavourite: false),
@ -252,6 +285,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: nil, notificationMode: nil,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false) isFavourite: false)

View File

@ -224,7 +224,8 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
case .atRoom: case .atRoom:
attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range) attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range)
case .roomAlias(let alias): case .roomAlias(let alias):
if let url = try? matrixToRoomAliasPermalink(roomAlias: alias) { if let urlString = try? matrixToRoomAliasPermalink(roomAlias: alias),
let url = URL(string: urlString) {
attributedString.addAttribute(.link, value: url, range: match.range) attributedString.addAttribute(.link, value: url, range: match.range)
} }
case .matrixURI(let uri): case .matrixURI(let uri):
@ -282,13 +283,13 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
case .user(let userID): case .user(let userID):
mentionBuilder.handleUserMention(for: attributedString, in: range, url: url, userID: userID, userDisplayName: nil) mentionBuilder.handleUserMention(for: attributedString, in: range, url: url, userID: userID, userDisplayName: nil)
case .room(let roomID): case .room(let roomID):
attributedString.addAttributes([.MatrixRoomID: roomID], range: range) mentionBuilder.handleRoomIDMention(for: attributedString, in: range, url: url, roomID: roomID)
case .roomAlias(let alias): case .roomAlias(let alias):
attributedString.addAttributes([.MatrixRoomAlias: alias], range: range) mentionBuilder.handleRoomAliasMention(for: attributedString, in: range, url: url, roomAlias: alias)
case .eventOnRoomId(let roomID, let eventID): case .eventOnRoomId(let roomID, let eventID):
attributedString.addAttributes([.MatrixEventOnRoomID: EventOnRoomIDAttribute.Value(roomID: roomID, eventID: eventID)], range: range) mentionBuilder.handleEventOnRoomIDMention(for: attributedString, in: range, url: url, eventID: eventID, roomID: roomID)
case .eventOnRoomAlias(let alias, let eventID): case .eventOnRoomAlias(let alias, let eventID):
attributedString.addAttributes([.MatrixEventOnRoomAlias: EventOnRoomAliasAttribute.Value(alias: alias, eventID: eventID)], range: range) mentionBuilder.handleEventOnRoomAliasMention(for: attributedString, in: range, url: url, eventID: eventID, roomAlias: alias)
} }
} }
} }
@ -364,6 +365,10 @@ extension NSAttributedString.Key {
protocol MentionBuilderProtocol { protocol MentionBuilderProtocol {
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?) func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?)
func handleRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomID: String)
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String)
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 handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange)
} }

View File

@ -9,13 +9,20 @@ import Foundation
import UIKit import UIKit
struct MentionBuilder: MentionBuilderProtocol { struct MentionBuilder: MentionBuilderProtocol {
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?) { struct AttributesToRestore {
let attributes = attributedString.attributes(at: 0, longestEffectiveRange: nil, in: range) let font: UIFont
let font = attributes[.font] as? UIFont ?? .preferredFont(forTextStyle: .body) let blockquote: Bool?
let blockquote = attributes[.MatrixBlockquote] let foregroundColor: UIColor
let foregroundColor = attributes[.foregroundColor] as? UIColor ?? .compound.textPrimary }
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 { guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
attributedString.addAttribute(.MatrixUserID, value: userID, range: range) attributedString.addAttribute(.MatrixUserID, value: userID, range: range)
@ -26,34 +33,151 @@ struct MentionBuilder: MentionBuilderProtocol {
return return
} }
var attachmentAttributes: [NSAttributedString.Key: Any] = [.link: url, .MatrixUserID: userID, .font: font, .foregroundColor: foregroundColor] var attachmentAttributes: [NSAttributedString.Key: Any] = [.link: url,
if let blockquote { .MatrixUserID: userID,
// mentions can be in blockquotes, so if the replaced string was in one, we keep the attribute .font: attributesToRestore.font,
attachmentAttributes[.MatrixBlockquote] = blockquote .foregroundColor: attributesToRestore.foregroundColor]
} attachmentAttributes.addBlockquoteIfNeeded(attributesToRestore.blockquote)
let attachmentString = NSMutableAttributedString(attachment: attachment) attachmentAttributes.addMatrixUsernameIfNeeded(userDisplayName)
attachmentString.addAttributes(attachmentAttributes, range: NSRange(location: 0, length: attachmentString.length))
attributedString.replaceCharacters(in: range, with: attachmentString) setPillAttachment(attachment: attachment,
attributedString: attributedString,
in: range,
with: attachmentAttributes)
} }
func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) { func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) {
let attributes = attributedString.attributes(at: 0, longestEffectiveRange: nil, in: range) let attributesToRestore = getAttributesToRestore(for: attributedString, in: range)
let font = attributes[.font] as? UIFont ?? .preferredFont(forTextStyle: .body)
let blockquote = attributes[.MatrixBlockquote] let attachmentData = PillTextAttachmentData(type: .allUsers, font: attributesToRestore.font)
let foregroundColor = attributes[.foregroundColor] as? UIColor ?? .compound.textPrimary
let attachmentData = PillTextAttachmentData(type: .allUsers, font: font)
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else { guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
return return
} }
var attachmentAttributes: [NSAttributedString.Key: Any] = [.font: font, .MatrixAllUsersMention: true, .foregroundColor: foregroundColor] var attachmentAttributes: [NSAttributedString.Key: Any] = [.font: attributesToRestore.font,
if let blockquote { .MatrixAllUsersMention: true,
// mentions can be in blockquotes, so if the replaced string was in one, we keep the attribute .foregroundColor: attributesToRestore.foregroundColor]
attachmentAttributes[.MatrixBlockquote] = blockquote 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) 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) 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
}
}
}

View File

@ -45,7 +45,7 @@ final class PillAttachmentViewProvider: NSTextAttachmentViewProvider, NSSecureCo
let mediaProvider: MediaProviderProtocol? let mediaProvider: MediaProviderProtocol?
if ProcessInfo.isXcodePreview || ProcessInfo.isRunningTests { if ProcessInfo.isXcodePreview || ProcessInfo.isRunningTests {
// The mock viewModel simulates the loading logic for testing purposes // 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()) mediaProvider = MediaProviderMock(configuration: .init())
} else if let timelineContext = delegate?.timelineContext { } else if let timelineContext = delegate?.timelineContext {
context = PillContext(timelineContext: timelineContext, data: pillData) context = PillContext(timelineContext: timelineContext, data: pillData)

View File

@ -10,75 +10,77 @@ import Foundation
@MainActor @MainActor
final class PillContext: ObservableObject { final class PillContext: ObservableObject {
struct PillViewState: Equatable { @Published var viewState: PillViewState = .undefined
let isOwnMention: Bool
let displayText: String
}
@Published private(set) var viewState: PillViewState let data: PillTextAttachmentData
var cancellable: AnyCancellable?
private var cancellable: AnyCancellable?
init(timelineContext: TimelineViewModel.Context, data: PillTextAttachmentData) { init(timelineContext: TimelineViewModel.Context, data: PillTextAttachmentData) {
switch data.type { self.data = data
case let .user(id): timelineContext.viewState.pillContextUpdater?(self)
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)
}
} }
} }
extension PillContext { extension PillContext {
enum MockType { static func mock(viewState: PillViewState, delay: Duration? = nil) -> PillContext {
case loadUser(isOwn: Bool) // This is just for previews so the internal data doesn't really matter
case loadedUser(isOwn: Bool) let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body)))
case allUsers 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 { case mention(isOwnMention: Bool, displayText: String)
let testID = "@test:test.com" case reference(avatar: PillImage, displayText: String)
let pillType: PillType case undefined
switch type {
case .loadUser(let isOwn): var isOwnMention: Bool {
pillType = .user(userID: testID) switch self {
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body))) case .mention(let isOwnMention, _):
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: testID) return isOwnMention
Task { default:
try? await Task.sleep(for: .seconds(2)) return false
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: "@Test Long Display Text") }
} }
return viewModel
case .loadedUser(let isOwn): var displayText: String {
pillType = .user(userID: "@test:test.com") switch self {
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body))) case .mention(_, let displayText), .reference(_, let displayText):
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: "@Very Very Long Test Display Text") return displayText
return viewModel case .undefined:
case .allUsers: return ""
pillType = .allUsers }
return PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body))) }
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
} }
} }
} }

View File

@ -9,6 +9,21 @@ import Foundation
import UIKit import UIKit
enum PillType: Codable, Equatable { 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 /// A pill that mentions a user
case user(userID: String) case user(userID: String)
/// A pill that mentions all users in a room /// A pill that mentions all users in a room

View File

@ -5,6 +5,7 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
// //
import Compound
import SwiftUI import SwiftUI
struct PillView: View { struct PillView: View {
@ -22,18 +23,42 @@ struct PillView: View {
} }
var body: some View { var body: some View {
Text(context.viewState.displayText) mainContent
.font(.compound.bodyLGSemibold)
.foregroundColor(textColor)
.lineLimit(1)
.padding(.leading, 4)
.padding(.trailing, 6)
.padding(.vertical, 1)
.background { Capsule().foregroundColor(backgroundColor) }
.onChange(of: context.viewState.displayText) { .onChange(of: context.viewState.displayText) {
didChangeText() 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 { struct PillView_Previews: PreviewProvider, TestablePreview {
@ -41,24 +66,27 @@ struct PillView_Previews: PreviewProvider, TestablePreview {
static var previews: some View { static var previews: some View {
PillView(mediaProvider: mockMediaProvider, PillView(mediaProvider: mockMediaProvider,
context: PillContext.mock(type: .loadUser(isOwn: false))) { } context: PillContext.mock(viewState: .mention(isOwnMention: false,
displayText: "@Alice"))) { }
.frame(maxWidth: PillConstants.mockMaxWidth) .frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("Loading") .previewDisplayName("User")
PillView(mediaProvider: mockMediaProvider, 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) .frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("Loading Own") .previewDisplayName("User with a long name")
PillView(mediaProvider: mockMediaProvider, PillView(mediaProvider: mockMediaProvider,
context: PillContext.mock(type: .loadedUser(isOwn: false))) { } context: PillContext.mock(viewState: .mention(isOwnMention: true,
displayText: "@Alice"))) { }
.frame(maxWidth: PillConstants.mockMaxWidth) .frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("Loaded Long") .previewDisplayName("Own user")
PillView(mediaProvider: mockMediaProvider, 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) .frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("Loaded Long Own") .previewDisplayName("Room")
PillView(mediaProvider: mockMediaProvider, PillView(mediaProvider: mockMediaProvider,
context: PillContext.mock(type: .allUsers)) { } context: PillContext.mock(viewState: .reference(avatar: .link, displayText: L10n.screenRoomEventPill("Room")))) { }
.frame(maxWidth: PillConstants.mockMaxWidth) .frame(maxWidth: PillConstants.mockMaxWidth)
.previewDisplayName("All Users") .previewDisplayName("Message link")
} }
} }

View File

@ -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. // In the future we might use this to do some customisation in what is plain text used to represent mentions.
struct PlainMentionBuilder: MentionBuilderProtocol { 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 handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) { }
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?) { 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) attributedString.insert(NSAttributedString(string: "@"), at: range.location)
} }
func handleRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomID: String) { }
} }

View File

@ -193,6 +193,7 @@ private extension HomeScreenRoom {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: nil, notificationMode: nil,
canonicalAlias: "#footest:somewhere.org", canonicalAlias: "#footest:somewhere.org",
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false) isFavourite: false)
@ -220,6 +221,7 @@ private extension HomeScreenRoom {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: nil, notificationMode: nil,
canonicalAlias: alias, canonicalAlias: alias,
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false) isFavourite: false)

View File

@ -157,6 +157,7 @@ private extension HomeScreenRoom {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: nil, notificationMode: nil,
canonicalAlias: "#footest:somewhere.org", canonicalAlias: "#footest:somewhere.org",
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false) isFavourite: false)
@ -184,6 +185,7 @@ private extension HomeScreenRoom {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: nil, notificationMode: nil,
canonicalAlias: alias, canonicalAlias: alias,
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false) isFavourite: false)

View File

@ -19,6 +19,7 @@ struct MediaEventsTimelineScreenCoordinatorParameters {
let emojiProvider: EmojiProviderProtocol let emojiProvider: EmojiProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol let userIndicatorController: UserIndicatorControllerProtocol
let timelineControllerFactory: TimelineControllerFactoryProtocol let timelineControllerFactory: TimelineControllerFactoryProtocol
let clientProxy: ClientProxyProtocol
} }
enum MediaEventsTimelineScreenCoordinatorAction { enum MediaEventsTimelineScreenCoordinatorAction {
@ -49,7 +50,8 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: parameters.emojiProvider, emojiProvider: parameters.emojiProvider,
timelineControllerFactory: parameters.timelineControllerFactory) timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy)
let filesTimelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, let filesTimelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
timelineController: parameters.filesTimelineController, timelineController: parameters.filesTimelineController,
@ -61,7 +63,8 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: parameters.emojiProvider, emojiProvider: parameters.emojiProvider,
timelineControllerFactory: parameters.timelineControllerFactory) timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy)
viewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: mediaTimelineViewModel, viewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: mediaTimelineViewModel,
filesTimelineViewModel: filesTimelineViewModel, filesTimelineViewModel: filesTimelineViewModel,

View File

@ -274,6 +274,7 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
} }
} }

View File

@ -17,6 +17,7 @@ struct PinnedEventsTimelineScreenCoordinatorParameters {
let appMediator: AppMediatorProtocol let appMediator: AppMediatorProtocol
let emojiProvider: EmojiProviderProtocol let emojiProvider: EmojiProviderProtocol
let timelineControllerFactory: TimelineControllerFactoryProtocol let timelineControllerFactory: TimelineControllerFactoryProtocol
let clientProxy: ClientProxyProtocol
} }
enum PinnedEventsTimelineScreenCoordinatorAction { enum PinnedEventsTimelineScreenCoordinatorAction {
@ -53,7 +54,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: parameters.emojiProvider, emojiProvider: parameters.emojiProvider,
timelineControllerFactory: parameters.timelineControllerFactory) timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy)
} }
func start() { func start() {

View File

@ -99,7 +99,8 @@ struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
}() }()
static var previews: some View { static var previews: some View {

View File

@ -86,7 +86,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
appSettings: parameters.appSettings, appSettings: parameters.appSettings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: parameters.emojiProvider, emojiProvider: parameters.emojiProvider,
timelineControllerFactory: parameters.timelineControllerFactory) timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy)
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight, wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight, maxCompressedHeight: ComposerConstant.maxHeight,

View File

@ -266,7 +266,8 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
static var previews: some View { static var previews: some View {
NavigationStack { NavigationStack {

View File

@ -41,6 +41,7 @@ class TimelineInteractionHandler {
private let emojiProvider: EmojiProviderProtocol private let emojiProvider: EmojiProviderProtocol
private let timelineControllerFactory: TimelineControllerFactoryProtocol private let timelineControllerFactory: TimelineControllerFactoryProtocol
private let pollInteractionHandler: PollInteractionHandlerProtocol private let pollInteractionHandler: PollInteractionHandlerProtocol
private let clientProxy: ClientProxyProtocol
private let actionsSubject: PassthroughSubject<TimelineInteractionHandlerAction, Never> = .init() private let actionsSubject: PassthroughSubject<TimelineInteractionHandlerAction, Never> = .init()
var actions: AnyPublisher<TimelineInteractionHandlerAction, Never> { var actions: AnyPublisher<TimelineInteractionHandlerAction, Never> {
@ -66,7 +67,8 @@ class TimelineInteractionHandler {
appSettings: AppSettings, appSettings: AppSettings,
analyticsService: AnalyticsService, analyticsService: AnalyticsService,
emojiProvider: EmojiProviderProtocol, emojiProvider: EmojiProviderProtocol,
timelineControllerFactory: TimelineControllerFactoryProtocol) { timelineControllerFactory: TimelineControllerFactoryProtocol,
clientProxy: ClientProxyProtocol) {
self.roomProxy = roomProxy self.roomProxy = roomProxy
self.timelineController = timelineController self.timelineController = timelineController
self.mediaProvider = mediaProvider self.mediaProvider = mediaProvider
@ -79,6 +81,7 @@ class TimelineInteractionHandler {
self.analyticsService = analyticsService self.analyticsService = analyticsService
self.emojiProvider = emojiProvider self.emojiProvider = emojiProvider
self.timelineControllerFactory = timelineControllerFactory self.timelineControllerFactory = timelineControllerFactory
self.clientProxy = clientProxy
pollInteractionHandler = PollInteractionHandler(analyticsService: analyticsService, roomProxy: roomProxy) pollInteractionHandler = PollInteractionHandler(analyticsService: analyticsService, roomProxy: roomProxy)
} }
@ -580,7 +583,8 @@ class TimelineInteractionHandler {
appSettings: appSettings, appSettings: appSettings,
analyticsService: analyticsService, analyticsService: analyticsService,
emojiProvider: emojiProvider, emojiProvider: emojiProvider,
timelineControllerFactory: timelineControllerFactory) timelineControllerFactory: timelineControllerFactory,
clientProxy: clientProxy)
return .displayMediaPreview(item: item, timelineViewModel: .new(timelineViewModel)) return .displayMediaPreview(item: item, timelineViewModel: .new(timelineViewModel))
} else { } else {

View File

@ -114,6 +114,9 @@ struct TimelineViewState: BindableState {
/// A closure providing the associated audio player state for an item in the timeline. /// A closure providing the associated audio player state for an item in the timeline.
var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)? var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)?
/// A closure that updates the associated pill context
var pillContextUpdater: (@MainActor (PillContext) -> Void)?
var emojiProvider: EmojiProviderProtocol var emojiProvider: EmojiProviderProtocol
} }

View File

@ -31,6 +31,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private let analyticsService: AnalyticsService private let analyticsService: AnalyticsService
private let emojiProvider: EmojiProviderProtocol private let emojiProvider: EmojiProviderProtocol
private let timelineControllerFactory: TimelineControllerFactoryProtocol private let timelineControllerFactory: TimelineControllerFactoryProtocol
private let clientProxy: ClientProxyProtocol
private let timelineInteractionHandler: TimelineInteractionHandler private let timelineInteractionHandler: TimelineInteractionHandler
@ -55,7 +56,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings: AppSettings, appSettings: AppSettings,
analyticsService: AnalyticsService, analyticsService: AnalyticsService,
emojiProvider: EmojiProviderProtocol, emojiProvider: EmojiProviderProtocol,
timelineControllerFactory: TimelineControllerFactoryProtocol) { timelineControllerFactory: TimelineControllerFactoryProtocol,
clientProxy: ClientProxyProtocol) {
self.timelineController = timelineController self.timelineController = timelineController
self.mediaProvider = mediaProvider self.mediaProvider = mediaProvider
self.mediaPlayerProvider = mediaPlayerProvider self.mediaPlayerProvider = mediaPlayerProvider
@ -66,6 +68,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
self.appMediator = appMediator self.appMediator = appMediator
self.emojiProvider = emojiProvider self.emojiProvider = emojiProvider
self.timelineControllerFactory = timelineControllerFactory self.timelineControllerFactory = timelineControllerFactory
self.clientProxy = clientProxy
let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider) let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
@ -80,7 +83,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings: appSettings, appSettings: appSettings,
analyticsService: analyticsService, analyticsService: analyticsService,
emojiProvider: emojiProvider, emojiProvider: emojiProvider,
timelineControllerFactory: timelineControllerFactory) timelineControllerFactory: timelineControllerFactory,
clientProxy: clientProxy)
super.init(initialViewState: TimelineViewState(timelineKind: timelineController.timelineKind, super.init(initialViewState: TimelineViewState(timelineKind: timelineController.timelineKind,
roomID: roomProxy.id, roomID: roomProxy.id,
@ -113,6 +117,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
return self.timelineInteractionHandler.audioPlayerState(for: itemID) return self.timelineInteractionHandler.audioPlayerState(for: itemID)
} }
state.pillContextUpdater = { [weak self] pillContext in
self?.pillContextUpdater(pillContext)
}
state.timelineState.paginationState = timelineController.paginationState state.timelineState.paginationState = timelineController.paginationState
buildTimelineViews(timelineItems: timelineController.timelineItems) buildTimelineViews(timelineItems: timelineController.timelineItems)
@ -826,6 +834,72 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
actionsSubject.send(.displayMessageForwarding(forwardingItem: .init(id: itemID, roomID: roomProxy.id, content: content))) actionsSubject.send(.displayMessageForwarding(forwardingItem: .init(id: itemID, roomID: roomProxy.id, content: content)))
} }
// MARK: Pills
private func pillContextUpdater(_ pillContext: PillContext) {
switch pillContext.data.type {
case let .user(id):
let isOwnMention = id == state.ownUserID
if let profile = state.members[id] {
var name = id
if let displayName = profile.displayName {
name = "@\(displayName)"
}
pillContext.viewState = .mention(isOwnMention: isOwnMention, displayText: name)
} else {
pillContext.viewState = .mention(isOwnMention: isOwnMention, displayText: id)
pillContext.cancellable = context.$viewState
.compactMap { $0.members[id] }
.sink { [weak pillContext] profile in
guard let pillContext else {
return
}
var name = id
if let displayName = profile.displayName {
name = "@\(displayName)"
}
pillContext.viewState = .mention(isOwnMention: isOwnMention, displayText: name)
pillContext.cancellable = nil
}
}
case .allUsers:
pillContext.viewState = .mention(isOwnMention: true, displayText: PillConstants.atRoom)
case .event(let room):
var pillViewState: PillViewState = .reference(avatar: .link, displayText: L10n.screenRoomEventPill(room.value))
defer {
pillContext.viewState = pillViewState
}
switch room {
case .roomAlias(let alias):
guard let roomSummary = clientProxy.roomSummaryForAlias(alias) else {
return
}
// We always show the link image for event permalinks
pillViewState = .reference(avatar: .link, displayText: L10n.screenRoomEventPill(roomSummary.name))
case .roomID(let id):
guard let roomSummary = clientProxy.roomSummaryForIdentifier(id) else {
return
}
// We always show the link image for event permalinks
pillViewState = .reference(avatar: .link, displayText: L10n.screenRoomEventPill(roomSummary.name))
}
case .roomAlias(let alias):
guard let roomSummary = clientProxy.roomSummaryForAlias(alias) else {
pillContext.viewState = .reference(avatar: .link, displayText: alias)
return
}
pillContext.viewState = .reference(avatar: .roomAvatar(roomSummary.avatar), displayText: roomSummary.name)
case .roomID(let id):
guard let roomSummary = clientProxy.roomSummaryForIdentifier(id) else {
pillContext.viewState = .reference(avatar: .link, displayText: id)
return
}
pillContext.viewState = .reference(avatar: .roomAvatar(roomSummary.avatar), displayText: roomSummary.name)
}
}
// MARK: - User Indicators // MARK: - User Indicators
private func showFocusLoadingIndicator() { private func showFocusLoadingIndicator() {
@ -896,7 +970,8 @@ extension TimelineViewModel {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
} }
} }

View File

@ -53,7 +53,8 @@ struct ReadReceiptsSummaryView_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
return mock return mock
}() }()

View File

@ -339,7 +339,8 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
}() }()
static var previews: some View { static var previews: some View {

View File

@ -88,8 +88,9 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
static let singleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now")] static let singleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now")]
static let doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"), static let doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
ReadReceipt(userID: RoomMemberProxyMock.mockBob.userID, formattedTimestamp: "Before")] ReadReceipt(userID: RoomMemberProxyMock.mockBob.userID, formattedTimestamp: "Before")]

View File

@ -142,8 +142,6 @@ struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
let htmlStrings = [ let htmlStrings = [
""" """
Plain text\n Plain text\n
@bob:matrix.org\n
#room:matrix.org\n
!room:matrix.org\n !room:matrix.org\n
https://www.matrix.org\n https://www.matrix.org\n
www.matrix.org\n www.matrix.org\n

View File

@ -98,7 +98,8 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
static var previews: some View { static var previews: some View {
NavigationStack { NavigationStack {

View File

@ -91,7 +91,8 @@ struct TimelineView_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
static var previews: some View { static var previews: some View {
NavigationStack { NavigationStack {

View File

@ -515,6 +515,18 @@ class ClientProxy: ClientProxyProtocol {
return .failure(.sdkError(error)) return .failure(.sdkError(error))
} }
} }
func roomSummaryForIdentifier(_ identifier: String) -> RoomSummary? {
// the alternate room summary provider is not impacted by filtering
alternateRoomSummaryProvider?.roomListPublisher.value.first { $0.id == identifier }
}
func roomSummaryForAlias(_ alias: String) -> RoomSummary? {
// the alternate room summary provider is not impacted by filtering
alternateRoomSummaryProvider?.roomListPublisher.value.first { roomSummary in
roomSummary.canonicalAlias == alias || roomSummary.alternativeAliases.contains(alias)
}
}
func loadUserDisplayName() async -> Result<Void, ClientProxyError> { func loadUserDisplayName() async -> Result<Void, ClientProxyError> {
do { do {

View File

@ -142,6 +142,10 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func roomPreviewForIdentifier(_ identifier: String, via: [String]) async -> Result<RoomPreviewProxyProtocol, ClientProxyError> func roomPreviewForIdentifier(_ identifier: String, via: [String]) async -> Result<RoomPreviewProxyProtocol, ClientProxyError>
func roomSummaryForIdentifier(_ identifier: String) -> RoomSummary?
func roomSummaryForAlias(_ alias: String) -> RoomSummary?
@discardableResult func loadUserDisplayName() async -> Result<Void, ClientProxyError> @discardableResult func loadUserDisplayName() async -> Result<Void, ClientProxyError>
func setUserDisplayName(_ name: String) async -> Result<Void, ClientProxyError> func setUserDisplayName(_ name: String) async -> Result<Void, ClientProxyError>

View File

@ -7,6 +7,7 @@
import Foundation import Foundation
/// A preview object for the Room. useful to get all the possible info for rooms to which the user is not invited to
// sourcery: AutoMockable // sourcery: AutoMockable
protocol RoomPreviewProxyProtocol { protocol RoomPreviewProxyProtocol {
var info: RoomPreviewInfoProxy { get } var info: RoomPreviewInfoProxy { get }

View File

@ -18,6 +18,7 @@ enum RoomProxyError: Error {
case missingTransactionID case missingTransactionID
} }
/// An enum that describes the relationship between the current user and the room, and contains a reference to the specific implementation of the `RoomProxy`.
enum RoomProxyType { enum RoomProxyType {
case joined(JoinedRoomProxyProtocol) case joined(JoinedRoomProxyProtocol)
case invited(InvitedRoomProxyProtocol) case invited(InvitedRoomProxyProtocol)

View File

@ -8,6 +8,7 @@
import Foundation import Foundation
import MatrixRustSDK import MatrixRustSDK
/// A quick summary of a Room, useful to describe and give quick informations for the room list
struct RoomSummary { struct RoomSummary {
enum JoinRequestType { enum JoinRequestType {
case invite(inviter: RoomMemberProxyProtocol?) case invite(inviter: RoomMemberProxyProtocol?)
@ -45,6 +46,7 @@ struct RoomSummary {
let unreadNotificationsCount: UInt let unreadNotificationsCount: UInt
let notificationMode: RoomNotificationModeProxy? let notificationMode: RoomNotificationModeProxy?
let canonicalAlias: String? let canonicalAlias: String?
let alternativeAliases: Set<String>
let hasOngoingCall: Bool let hasOngoingCall: Bool
@ -99,6 +101,7 @@ extension RoomSummary {
unreadNotificationsCount = hasUnreadNotifications ? 1 : 0 unreadNotificationsCount = hasUnreadNotifications ? 1 : 0
notificationMode = settingsMode notificationMode = settingsMode
canonicalAlias = nil canonicalAlias = nil
alternativeAliases = []
hasOngoingCall = false hasOngoingCall = false
joinRequestType = nil joinRequestType = nil

View File

@ -275,6 +275,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
unreadNotificationsCount: UInt(roomInfo.numUnreadNotifications), unreadNotificationsCount: UInt(roomInfo.numUnreadNotifications),
notificationMode: notificationMode, notificationMode: notificationMode,
canonicalAlias: roomInfo.canonicalAlias, canonicalAlias: roomInfo.canonicalAlias,
alternativeAliases: .init(roomInfo.alternativeAliases),
hasOngoingCall: roomInfo.hasRoomCall, hasOngoingCall: roomInfo.hasRoomCall,
isMarkedUnread: roomInfo.isMarkedUnread, isMarkedUnread: roomInfo.isMarkedUnread,
isFavourite: roomInfo.isFavourite) isFavourite: roomInfo.isFavourite)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -425,14 +425,88 @@ class AttributedStringBuilderTests: XCTestCase {
XCTAssertEqual(numberOfBlockquotes, 3, "Couldn't find all the blockquotes") XCTAssertEqual(numberOfBlockquotes, 3, "Couldn't find all the blockquotes")
} }
func testUserMentionAtachment() { func testUserPermalinkMentionAtachment() {
let string = "https://matrix.to/#/@test:matrix.org" let string = "https://matrix.to/#/@test:matrix.org"
let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) let attributedStringFromHTML = attributedStringBuilder.fromHTML(string)
XCTAssertNotNil(attributedStringFromHTML?.attachment) XCTAssertNotNil(attributedStringFromHTML?.attachment)
XCTAssertNotNil(attributedStringFromHTML?.link) XCTAssertEqual(attributedStringFromHTML?.userID, "@test:matrix.org")
XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, string)
let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) let attributedStringFromPlain = attributedStringBuilder.fromPlain(string)
XCTAssertNotNil(attributedStringFromPlain?.attachment) XCTAssertNotNil(attributedStringFromPlain?.attachment)
XCTAssertNotNil(attributedStringFromHTML?.link) XCTAssertEqual(attributedStringFromPlain?.userID, "@test:matrix.org")
XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, string)
}
func testUserIDMentionAtachment() {
let string = "@test:matrix.org"
let attributedStringFromHTML = attributedStringBuilder.fromHTML(string)
XCTAssertNotNil(attributedStringFromHTML?.attachment)
XCTAssertEqual(attributedStringFromHTML?.userID, "@test:matrix.org")
XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, "https://matrix.to/#/@test:matrix.org")
let attributedStringFromPlain = attributedStringBuilder.fromPlain(string)
XCTAssertNotNil(attributedStringFromPlain?.attachment)
XCTAssertEqual(attributedStringFromPlain?.userID, "@test:matrix.org")
XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, "https://matrix.to/#/@test:matrix.org")
}
func testRoomIDPermalinkMentionAttachment() {
let string = "https://matrix.to/#/!test:matrix.org"
let attributedStringFromHTML = attributedStringBuilder.fromHTML(string)
XCTAssertNotNil(attributedStringFromHTML?.attachment)
XCTAssertEqual(attributedStringFromHTML?.roomID, "!test:matrix.org")
XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, string)
let attributedStringFromPlain = attributedStringBuilder.fromPlain(string)
XCTAssertNotNil(attributedStringFromPlain?.attachment)
XCTAssertEqual(attributedStringFromHTML?.roomID, "!test:matrix.org")
XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, string)
}
func testRoomAliasPermalinkMentionAttachment() {
let string = "https://matrix.to/#/#test:matrix.org"
let attributedStringFromHTML = attributedStringBuilder.fromHTML(string)
XCTAssertNotNil(attributedStringFromHTML?.attachment)
XCTAssertEqual(attributedStringFromHTML?.roomAlias, "#test:matrix.org")
XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org")
let attributedStringFromPlain = attributedStringBuilder.fromPlain(string)
XCTAssertNotNil(attributedStringFromPlain?.attachment)
XCTAssertEqual(attributedStringFromHTML?.roomAlias, "#test:matrix.org")
XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org")
}
func testRoomAliasMentionAttachment() {
let string = "#test:matrix.org"
let attributedStringFromHTML = attributedStringBuilder.fromHTML(string)
XCTAssertNotNil(attributedStringFromHTML?.attachment)
XCTAssertEqual(attributedStringFromHTML?.roomAlias, "#test:matrix.org")
XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org")
let attributedStringFromPlain = attributedStringBuilder.fromPlain(string)
XCTAssertNotNil(attributedStringFromPlain?.attachment)
XCTAssertEqual(attributedStringFromHTML?.roomAlias, "#test:matrix.org")
XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org")
}
func testEventRoomIDPermalinkMentionAttachment() {
let string = "https://matrix.to/#/!test:matrix.org/$test"
let attributedStringFromHTML = attributedStringBuilder.fromHTML(string)
XCTAssertNotNil(attributedStringFromHTML?.attachment)
XCTAssertEqual(attributedStringFromHTML?.eventOnRoomID, .some(.init(roomID: "!test:matrix.org", eventID: "$test")))
XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, string)
let attributedStringFromPlain = attributedStringBuilder.fromPlain(string)
XCTAssertNotNil(attributedStringFromPlain?.attachment)
XCTAssertEqual(attributedStringFromPlain?.eventOnRoomID, .some(.init(roomID: "!test:matrix.org", eventID: "$test")))
XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, string)
}
func testEventRoomAliasPermalinkMentionAttachment() {
let string = "https://matrix.to/#/#test:matrix.org/$test"
let attributedStringFromHTML = attributedStringBuilder.fromHTML(string)
XCTAssertNotNil(attributedStringFromHTML?.attachment)
XCTAssertEqual(attributedStringFromHTML?.eventOnRoomAlias, .some(.init(alias: "#test:matrix.org", eventID: "$test")))
XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org/$test")
let attributedStringFromPlain = attributedStringBuilder.fromPlain(string)
XCTAssertNotNil(attributedStringFromPlain?.attachment)
XCTAssertEqual(attributedStringFromPlain?.eventOnRoomAlias, .some(.init(alias: "#test:matrix.org", eventID: "$test")))
XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org/$test")
} }
func testUserMentionAtachmentInBlockQuotes() { func testUserMentionAtachmentInBlockQuotes() {
@ -631,7 +705,7 @@ class AttributedStringBuilderTests: XCTestCase {
XCTAssertEqual(foundLink, url) XCTAssertEqual(foundLink, url)
XCTAssertEqual(foundAttachments, 2) XCTAssertEqual(foundAttachments, 2)
} }
// MARK: - Private // MARK: - Private
private func checkLinkIn(attributedString: AttributedString?, expectedLink: String, expectedRuns: Int) { private func checkLinkIn(attributedString: AttributedString?, expectedLink: String, expectedRuns: Int) {

View File

@ -34,6 +34,7 @@ class HomeScreenRoomTests: XCTestCase {
unreadNotificationsCount: unreadNotificationsCount, unreadNotificationsCount: unreadNotificationsCount,
notificationMode: notificationMode, notificationMode: notificationMode,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: hasOngoingCall, hasOngoingCall: hasOngoingCall,
isMarkedUnread: isMarkedUnread, isMarkedUnread: isMarkedUnread,
isFavourite: false) isFavourite: false)

View File

@ -92,6 +92,7 @@ class LoggingTests: XCTestCase {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: nil, notificationMode: nil,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false) isFavourite: false)

View File

@ -12,7 +12,7 @@ import XCTest
@MainActor @MainActor
class PillContextTests: XCTestCase { class PillContextTests: XCTestCase {
func testUser() async throws { func testUser() async {
let id = "@test:matrix.org" let id = "@test:matrix.org"
let proxyMock = JoinedRoomProxyMock(.init(name: "Test")) let proxyMock = JoinedRoomProxyMock(.init(name: "Test"))
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([]) let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
@ -27,7 +27,8 @@ class PillContextTests: XCTestCase {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body))) let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention) XCTAssertFalse(context.viewState.isOwnMention)
@ -39,10 +40,11 @@ class PillContextTests: XCTestCase {
await Task.yield() await Task.yield()
XCTAssertFalse(context.viewState.isOwnMention) XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertNil(context.viewState.image)
XCTAssertEqual(context.viewState.displayText, "@\(name)") XCTAssertEqual(context.viewState.displayText, "@\(name)")
} }
func testOwnUser() async throws { func testOwnUser() {
let id = "@test:matrix.org" let id = "@test:matrix.org"
let proxyMock = JoinedRoomProxyMock(.init(name: "Test", ownUserID: id)) let proxyMock = JoinedRoomProxyMock(.init(name: "Test", ownUserID: id))
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([]) let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
@ -57,13 +59,15 @@ class PillContextTests: XCTestCase {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body))) let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body)))
XCTAssertNil(context.viewState.image)
XCTAssertTrue(context.viewState.isOwnMention) XCTAssertTrue(context.viewState.isOwnMention)
} }
func testAllUsers() async throws { func testAllUsers() {
let avatarURL = URL(string: "https://matrix.jpg") let avatarURL = URL(string: "https://matrix.jpg")
let id = "test_room" let id = "test_room"
let displayName = "Test" let displayName = "Test"
@ -80,10 +84,216 @@ class PillContextTests: XCTestCase {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body))) let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body)))
XCTAssertTrue(context.viewState.isOwnMention) XCTAssertTrue(context.viewState.isOwnMention)
XCTAssertNil(context.viewState.image)
XCTAssertEqual(context.viewState.displayText, PillConstants.atRoom) XCTAssertEqual(context.viewState.displayText, PillConstants.atRoom)
} }
func testRoomIDMention() {
let proxyMock = JoinedRoomProxyMock(.init())
let mockController = MockTimelineController()
let clientMock = ClientProxyMock(.init())
clientMock.roomSummaryForIdentifierReturnValue = .mock(id: "1", name: "Foundation 🔭🪐🌌")
mockController.roomProxy = proxyMock
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: clientMock)
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .roomID("1"), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertFalse(context.viewState.isUndefined)
XCTAssertEqual(context.viewState.image, .roomAvatar(.room(id: "1", name: "Foundation 🔭🪐🌌", avatarURL: nil)))
XCTAssertEqual(context.viewState.displayText, "Foundation 🔭🪐🌌")
}
func testRoomIDMentionMissingRoom() {
let proxyMock = JoinedRoomProxyMock(.init())
let mockController = MockTimelineController()
mockController.roomProxy = proxyMock
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .roomID("1"), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertFalse(context.viewState.isUndefined)
XCTAssertEqual(context.viewState.image, .link)
XCTAssertEqual(context.viewState.displayText, "1")
}
func testRoomAliasMention() {
let proxyMock = JoinedRoomProxyMock(.init())
let mockController = MockTimelineController()
mockController.roomProxy = proxyMock
let clientMock = ClientProxyMock(.init())
clientMock.roomSummaryForAliasReturnValue = .mock(id: "2",
name: "Foundation and Empire",
canonicalAlias: "#foundation-and-empire:matrix.org")
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: clientMock)
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .roomAlias("#foundation-and-empire:matrix.org"), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertFalse(context.viewState.isUndefined)
XCTAssertEqual(context.viewState.image, .roomAvatar(.room(id: "2", name: "Foundation and Empire", avatarURL: nil)))
XCTAssertEqual(context.viewState.displayText, "Foundation and Empire")
}
func testRoomAliasMentionMissingRoom() {
let proxyMock = JoinedRoomProxyMock(.init())
let mockController = MockTimelineController()
mockController.roomProxy = proxyMock
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .roomAlias("#foundation-and-empire:matrix.org"), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertFalse(context.viewState.isUndefined)
XCTAssertEqual(context.viewState.image, .link)
XCTAssertEqual(context.viewState.displayText, "#foundation-and-empire:matrix.org")
}
func testEventOnRoomIDMention() {
let proxyMock = JoinedRoomProxyMock(.init())
let mockController = MockTimelineController()
mockController.roomProxy = proxyMock
let clientMock = ClientProxyMock(.init())
clientMock.roomSummaryForIdentifierReturnValue = .mock(id: "1", name: "Foundation 🔭🪐🌌")
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: clientMock)
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .event(room: .roomID("1")), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertFalse(context.viewState.isUndefined)
XCTAssertEqual(context.viewState.image, .link)
XCTAssertEqual(context.viewState.displayText, L10n.screenRoomEventPill("Foundation 🔭🪐🌌"))
}
func testEventOnRoomIDMentionMissingRoom() {
let proxyMock = JoinedRoomProxyMock(.init())
let mockController = MockTimelineController()
mockController.roomProxy = proxyMock
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .event(room: .roomID("1")), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertFalse(context.viewState.isUndefined)
XCTAssertEqual(context.viewState.image, .link)
XCTAssertEqual(context.viewState.displayText, L10n.screenRoomEventPill("1"))
}
func testEventOnRoomAliasMention() async throws {
let proxyMock = JoinedRoomProxyMock(.init())
let mockController = MockTimelineController()
mockController.roomProxy = proxyMock
let clientMock = ClientProxyMock(.init())
clientMock.roomSummaryForAliasReturnValue = .mock(id: "2",
name: "Foundation and Empire",
canonicalAlias: "#foundation-and-empire:matrix.org")
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: clientMock)
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .event(room: .roomAlias("#foundation-and-empire:matrix.org")), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertFalse(context.viewState.isUndefined)
XCTAssertEqual(context.viewState.image, .link)
XCTAssertEqual(context.viewState.displayText, L10n.screenRoomEventPill("Foundation and Empire"))
}
func testEventOnRoomAliasMentionMissingRoom() async throws {
let proxyMock = JoinedRoomProxyMock(.init())
let mockController = MockTimelineController()
mockController.roomProxy = proxyMock
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .event(room: .roomAlias("#foundation-and-empire:matrix.org")), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertFalse(context.viewState.isUndefined)
XCTAssertEqual(context.viewState.image, .link)
XCTAssertEqual(context.viewState.displayText, L10n.screenRoomEventPill("#foundation-and-empire:matrix.org"))
}
} }

View File

@ -68,6 +68,7 @@ class RoomSummaryTests: XCTestCase {
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
notificationMode: nil, notificationMode: nil,
canonicalAlias: nil, canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false, hasOngoingCall: false,
isMarkedUnread: false, isMarkedUnread: false,
isFavourite: false) isFavourite: false)

View File

@ -312,7 +312,8 @@ class TimelineViewModelTests: XCTestCase {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
return (viewModel, roomProxy, timelineProxy, timelineController) return (viewModel, roomProxy, timelineProxy, timelineController)
} }
@ -338,7 +339,8 @@ class TimelineViewModelTests: XCTestCase {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
let deferred = deferFulfillment(viewModel.context.$viewState) { value in let deferred = deferFulfillment(viewModel.context.$viewState) { value in
value.bindings.readReceiptsSummaryInfo?.orderedReceipts == receipts value.bindings.readReceiptsSummaryInfo?.orderedReceipts == receipts
@ -367,7 +369,8 @@ class TimelineViewModelTests: XCTestCase {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
XCTAssertEqual(configuration.pinnedEventIDs, viewModel.context.viewState.pinnedEventIDs) XCTAssertEqual(configuration.pinnedEventIDs, viewModel.context.viewState.pinnedEventIDs)
configuration.pinnedEventIDs = ["test1", "test2"] configuration.pinnedEventIDs = ["test1", "test2"]
@ -394,7 +397,8 @@ class TimelineViewModelTests: XCTestCase {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
var deferred = deferFulfillment(viewModel.context.$viewState) { value in var deferred = deferFulfillment(viewModel.context.$viewState) { value in
value.canCurrentUserPin value.canCurrentUserPin
@ -425,7 +429,8 @@ class TimelineViewModelTests: XCTestCase {
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics, analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init())) timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
} }
} }