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,
emojiProvider: emojiProvider,
userIndicatorController: userIndicatorController,
timelineControllerFactory: timelineControllerFactory)
timelineControllerFactory: timelineControllerFactory,
clientProxy: userSession.clientProxy)
let coordinator = MediaEventsTimelineScreenCoordinator(parameters: parameters)

View File

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

View File

@ -3225,6 +3225,146 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
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
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 {
static let mockRooms: [Element] = [
RoomSummary(roomListItem: RoomListItemSDKMock(),
@ -83,6 +108,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0,
notificationMode: .allMessages,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
@ -99,7 +125,8 @@ extension Array where Element == RoomSummary {
unreadMentionsCount: 0,
unreadNotificationsCount: 2,
notificationMode: .mute,
canonicalAlias: nil,
canonicalAlias: "#foundation-and-empire:matrix.org",
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
@ -117,6 +144,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0,
notificationMode: .mentionsAndKeywordsOnly,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
@ -134,6 +162,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 2,
notificationMode: .allMessages,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
@ -151,6 +180,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 1,
notificationMode: .allMessages,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: true,
isMarkedUnread: false,
isFavourite: false),
@ -168,6 +198,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0,
notificationMode: .mute,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: true,
isMarkedUnread: false,
isFavourite: false),
@ -185,6 +216,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0,
notificationMode: nil,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false)
@ -235,6 +267,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0,
notificationMode: nil,
canonicalAlias: "#footest:somewhere.org",
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
@ -252,6 +285,7 @@ extension Array where Element == RoomSummary {
unreadNotificationsCount: 0,
notificationMode: nil,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false)

View File

@ -224,7 +224,8 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
case .atRoom:
attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range)
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)
}
case .matrixURI(let uri):
@ -282,13 +283,13 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
case .user(let userID):
mentionBuilder.handleUserMention(for: attributedString, in: range, url: url, userID: userID, userDisplayName: nil)
case .room(let roomID):
attributedString.addAttributes([.MatrixRoomID: roomID], range: range)
mentionBuilder.handleRoomIDMention(for: attributedString, in: range, url: url, roomID: roomID)
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):
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):
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 {
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)
}

View File

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

View File

@ -45,7 +45,7 @@ final class PillAttachmentViewProvider: NSTextAttachmentViewProvider, NSSecureCo
let mediaProvider: MediaProviderProtocol?
if ProcessInfo.isXcodePreview || ProcessInfo.isRunningTests {
// The mock viewModel simulates the loading logic for testing purposes
context = PillContext.mock(type: .loadUser(isOwn: false))
context = PillContext.mock(viewState: .mention(isOwnMention: false, displayText: "Alice"), delay: .seconds(2))
mediaProvider = MediaProviderMock(configuration: .init())
} else if let timelineContext = delegate?.timelineContext {
context = PillContext(timelineContext: timelineContext, data: pillData)

View File

@ -10,75 +10,77 @@ import Foundation
@MainActor
final class PillContext: ObservableObject {
struct PillViewState: Equatable {
let isOwnMention: Bool
let displayText: String
}
@Published var viewState: PillViewState = .undefined
@Published private(set) var viewState: PillViewState
private var cancellable: AnyCancellable?
let data: PillTextAttachmentData
var cancellable: AnyCancellable?
init(timelineContext: TimelineViewModel.Context, data: PillTextAttachmentData) {
switch data.type {
case let .user(id):
let isOwnMention = id == timelineContext.viewState.ownUserID
if let profile = timelineContext.viewState.members[id] {
var name = id
if let displayName = profile.displayName {
name = "@\(displayName)"
}
viewState = PillViewState(isOwnMention: isOwnMention, displayText: name)
} else {
viewState = PillViewState(isOwnMention: isOwnMention, displayText: id)
cancellable = timelineContext.$viewState.sink { [weak self] viewState in
guard let self else {
return
}
if let profile = viewState.members[id] {
var name = id
if let displayName = profile.displayName {
name = "@\(displayName)"
}
self.viewState = PillViewState(isOwnMention: isOwnMention, displayText: name)
cancellable = nil
}
}
}
case .allUsers:
viewState = PillViewState(isOwnMention: true, displayText: PillConstants.atRoom)
}
self.data = data
timelineContext.viewState.pillContextUpdater?(self)
}
}
extension PillContext {
enum MockType {
case loadUser(isOwn: Bool)
case loadedUser(isOwn: Bool)
case allUsers
static func mock(viewState: PillViewState, delay: Duration? = nil) -> PillContext {
// This is just for previews so the internal data doesn't really matter
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body)))
if let delay {
viewModel.viewState = .mention(isOwnMention: false, displayText: "placeholder")
Task {
try? await Task.sleep(for: delay)
viewModel.viewState = viewState
}
} else {
viewModel.viewState = viewState
}
return viewModel
}
}
enum PillViewState: Equatable {
enum PillImage: Equatable {
case link
case roomAvatar(RoomAvatar)
}
static func mock(type: MockType) -> PillContext {
let testID = "@test:test.com"
let pillType: PillType
switch type {
case .loadUser(let isOwn):
pillType = .user(userID: testID)
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: testID)
Task {
try? await Task.sleep(for: .seconds(2))
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: "@Test Long Display Text")
case mention(isOwnMention: Bool, displayText: String)
case reference(avatar: PillImage, displayText: String)
case undefined
var isOwnMention: Bool {
switch self {
case .mention(let isOwnMention, _):
return isOwnMention
default:
return false
}
return viewModel
case .loadedUser(let isOwn):
pillType = .user(userID: "@test:test.com")
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: "@Very Very Long Test Display Text")
return viewModel
case .allUsers:
pillType = .allUsers
return PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
}
var displayText: String {
switch self {
case .mention(_, let displayText), .reference(_, let displayText):
return displayText
case .undefined:
return ""
}
}
var isUndefined: Bool {
switch self {
case .undefined:
return true
default:
return false
}
}
var image: PillImage? {
switch self {
case .reference(let avatar, _):
return avatar
default:
return nil
}
}
}

View File

@ -9,6 +9,21 @@ import Foundation
import UIKit
enum PillType: Codable, Equatable {
enum EventRoom: Codable, Equatable {
case roomAlias(String)
case roomID(String)
var value: String {
switch self {
case .roomAlias(let value), .roomID(let value):
return value
}
}
}
case event(room: EventRoom)
case roomAlias(String)
case roomID(String)
/// A pill that mentions a user
case user(userID: String)
/// A pill that mentions all users in a room

View File

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

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.
struct PlainMentionBuilder: MentionBuilderProtocol {
func handleEventOnRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomAlias: String) { }
func handleEventOnRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomID: String) { }
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String) { }
func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) { }
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?) {
@ -17,4 +23,6 @@ struct PlainMentionBuilder: MentionBuilderProtocol {
}
attributedString.insert(NSAttributedString(string: "@"), at: range.location)
}
func handleRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomID: String) { }
}

View File

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

View File

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

View File

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

View File

@ -274,6 +274,7 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
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 emojiProvider: EmojiProviderProtocol
let timelineControllerFactory: TimelineControllerFactoryProtocol
let clientProxy: ClientProxyProtocol
}
enum PinnedEventsTimelineScreenCoordinatorAction {
@ -53,7 +54,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: parameters.emojiProvider,
timelineControllerFactory: parameters.timelineControllerFactory)
timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy)
}
func start() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private let analyticsService: AnalyticsService
private let emojiProvider: EmojiProviderProtocol
private let timelineControllerFactory: TimelineControllerFactoryProtocol
private let clientProxy: ClientProxyProtocol
private let timelineInteractionHandler: TimelineInteractionHandler
@ -55,7 +56,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings: AppSettings,
analyticsService: AnalyticsService,
emojiProvider: EmojiProviderProtocol,
timelineControllerFactory: TimelineControllerFactoryProtocol) {
timelineControllerFactory: TimelineControllerFactoryProtocol,
clientProxy: ClientProxyProtocol) {
self.timelineController = timelineController
self.mediaProvider = mediaProvider
self.mediaPlayerProvider = mediaPlayerProvider
@ -66,6 +68,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
self.appMediator = appMediator
self.emojiProvider = emojiProvider
self.timelineControllerFactory = timelineControllerFactory
self.clientProxy = clientProxy
let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
@ -80,7 +83,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings: appSettings,
analyticsService: analyticsService,
emojiProvider: emojiProvider,
timelineControllerFactory: timelineControllerFactory)
timelineControllerFactory: timelineControllerFactory,
clientProxy: clientProxy)
super.init(initialViewState: TimelineViewState(timelineKind: timelineController.timelineKind,
roomID: roomProxy.id,
@ -113,6 +117,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
return self.timelineInteractionHandler.audioPlayerState(for: itemID)
}
state.pillContextUpdater = { [weak self] pillContext in
self?.pillContextUpdater(pillContext)
}
state.timelineState.paginationState = timelineController.paginationState
buildTimelineViews(timelineItems: timelineController.timelineItems)
@ -826,6 +834,72 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
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
private func showFocusLoadingIndicator() {
@ -896,7 +970,8 @@ extension TimelineViewModel {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
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,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
return mock
}()

View File

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

View File

@ -88,7 +88,8 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
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 doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),

View File

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

View File

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

View File

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

View File

@ -516,6 +516,18 @@ class ClientProxy: ClientProxyProtocol {
}
}
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> {
do {
let displayName = try await client.displayName()

View File

@ -142,6 +142,10 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
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>
func setUserDisplayName(_ name: String) async -> Result<Void, ClientProxyError>

View File

@ -7,6 +7,7 @@
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
protocol RoomPreviewProxyProtocol {
var info: RoomPreviewInfoProxy { get }

View File

@ -18,6 +18,7 @@ enum RoomProxyError: Error {
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 {
case joined(JoinedRoomProxyProtocol)
case invited(InvitedRoomProxyProtocol)

View File

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

View File

@ -275,6 +275,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
unreadNotificationsCount: UInt(roomInfo.numUnreadNotifications),
notificationMode: notificationMode,
canonicalAlias: roomInfo.canonicalAlias,
alternativeAliases: .init(roomInfo.alternativeAliases),
hasOngoingCall: roomInfo.hasRoomCall,
isMarkedUnread: roomInfo.isMarkedUnread,
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")
}
func testUserMentionAtachment() {
func testUserPermalinkMentionAtachment() {
let string = "https://matrix.to/#/@test:matrix.org"
let attributedStringFromHTML = attributedStringBuilder.fromHTML(string)
XCTAssertNotNil(attributedStringFromHTML?.attachment)
XCTAssertNotNil(attributedStringFromHTML?.link)
XCTAssertEqual(attributedStringFromHTML?.userID, "@test:matrix.org")
XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, string)
let attributedStringFromPlain = attributedStringBuilder.fromPlain(string)
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() {

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import XCTest
@MainActor
class PillContextTests: XCTestCase {
func testUser() async throws {
func testUser() async {
let id = "@test:matrix.org"
let proxyMock = JoinedRoomProxyMock(.init(name: "Test"))
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
@ -27,7 +27,8 @@ class PillContextTests: XCTestCase {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
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)))
XCTAssertFalse(context.viewState.isOwnMention)
@ -39,10 +40,11 @@ class PillContextTests: XCTestCase {
await Task.yield()
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertNil(context.viewState.image)
XCTAssertEqual(context.viewState.displayText, "@\(name)")
}
func testOwnUser() async throws {
func testOwnUser() {
let id = "@test:matrix.org"
let proxyMock = JoinedRoomProxyMock(.init(name: "Test", ownUserID: id))
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
@ -57,13 +59,15 @@ class PillContextTests: XCTestCase {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
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)))
XCTAssertNil(context.viewState.image)
XCTAssertTrue(context.viewState.isOwnMention)
}
func testAllUsers() async throws {
func testAllUsers() {
let avatarURL = URL(string: "https://matrix.jpg")
let id = "test_room"
let displayName = "Test"
@ -80,10 +84,216 @@ class PillContextTests: XCTestCase {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
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)))
XCTAssertTrue(context.viewState.isOwnMention)
XCTAssertNil(context.viewState.image)
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,
notificationMode: nil,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false)

View File

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