diff --git a/WireFoundation/Sources/WireFoundation/LeadingTrailingDebouncer.swift b/WireFoundation/Sources/WireFoundation/LeadingTrailingDebouncer.swift new file mode 100644 index 00000000000..526e186d3ed --- /dev/null +++ b/WireFoundation/Sources/WireFoundation/LeadingTrailingDebouncer.swift @@ -0,0 +1,84 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +public import Foundation + +/// A debouncer that triggers the action immediately on the first call (leading) +/// and once more after a delay if additional calls occur (trailing). +/// Useful for responding instantly but also handling final state after other input. +public final class LeadingTrailingDebouncer { + + private struct DebounceState { + var isCooldown = false + var pendingCall: (() -> Void)? + } + + private let cooldownTime: TimeInterval + private let queue: DispatchQueue + private var states: [AnyHashable: DebounceState] = [:] + + // Unique key for `nil` ID + private let nilKey = UUID() + + public init(cooldownTime: TimeInterval, queue: DispatchQueue = .main) { + self.cooldownTime = cooldownTime + self.queue = queue + } + + public func call(id: ID?, block: @escaping () -> Void) { + + let key: AnyHashable = id.map { AnyHashable($0) } ?? AnyHashable(nilKey) + + if states[key] == nil { + states[key] = DebounceState() + } + + var state = states[key]! + + if !state.isCooldown { + // LEADING: run immediately + block() + state.isCooldown = true + + queue.asyncAfter(deadline: .now() + cooldownTime) { [weak self] in + guard let self else { return } + + var updatedState = states[key] ?? DebounceState() + updatedState.isCooldown = false + + if let trailing = updatedState.pendingCall { + trailing() + updatedState.pendingCall = nil + updatedState.isCooldown = true + + queue.asyncAfter(deadline: .now() + cooldownTime) { + self.states[key]?.isCooldown = false + self.states[key]?.pendingCall = nil + } + } + + states[key] = updatedState + } + } else { + // Store for TRAILING + state.pendingCall = block + } + + states[key] = state + } +} diff --git a/WireFoundation/Sources/WireFoundation/ThreadSafeDictionary.swift b/WireFoundation/Sources/WireFoundation/ThreadSafeDictionary.swift new file mode 100644 index 00000000000..ececff25a7c --- /dev/null +++ b/WireFoundation/Sources/WireFoundation/ThreadSafeDictionary.swift @@ -0,0 +1,57 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +public class ThreadSafeDictionary { + + public init() {} + + private var dictionary = [Key: Value]() + private let queue = DispatchQueue(label: "com.example.dictionaryQueue") + + public func set(value: Value?, for key: Key) { + queue.async { + self.dictionary[key] = value + } + } + + public func get(for key: Key) -> Value? { + queue.sync { + self.dictionary[key] + } + } + + public func remove(for key: Key) { + queue.async { + self.dictionary.removeValue(forKey: key) + } + } + + public func allItems() -> [Key: Value] { + queue.sync { + self.dictionary + } + } + + public func reset() { + queue.async { + self.dictionary.removeAll() + } + } +} diff --git a/wire-ios-data-model/Source/ManagedObjectContext/CoreDataStack.swift b/wire-ios-data-model/Source/ManagedObjectContext/CoreDataStack.swift index 370c8169e99..8ff24ae5ca0 100644 --- a/wire-ios-data-model/Source/ManagedObjectContext/CoreDataStack.swift +++ b/wire-ios-data-model/Source/ManagedObjectContext/CoreDataStack.swift @@ -45,6 +45,7 @@ public protocol ContextProvider { var account: Account { get } var viewContext: NSManagedObjectContext { get } + func newBackgroundContext() -> NSManagedObjectContext var syncContext: NSManagedObjectContext { get } var searchContext: NSManagedObjectContext { get } var eventContext: NSManagedObjectContext { get } @@ -120,6 +121,14 @@ public class CoreDataStack: NSObject, CoreDataStackProtocol { messagesContainer.viewContext } + public func newBackgroundContext() -> NSManagedObjectContext { + #if DEBUG + return newBackgroundContextProvider?() ?? messagesContainer.newBackgroundContext() + #else + return messagesContainer.newBackgroundContext() + #endif + } + public lazy var syncContext: NSManagedObjectContext = messagesContainer.newBackgroundContext() public lazy var searchContext: NSManagedObjectContext = messagesContainer.newBackgroundContext() @@ -129,10 +138,13 @@ public class CoreDataStack: NSObject, CoreDataStackProtocol { public let accountContainer: URL public let applicationContainer: URL - let messagesContainer: PersistentContainer + public let messagesContainer: PersistentContainer let eventsContainer: PersistentContainer let dispatchGroup: ZMSDispatchGroup? + #if DEBUG + public var newBackgroundContextProvider: (() -> NSManagedObjectContext)? + #endif private let messagesMigrator: CoreDataMigrator private let eventsMigrator: CoreDataMigrator private var hasBeenClosed = false @@ -570,7 +582,7 @@ public class CoreDataStack: NSObject, CoreDataStackProtocol { // MARK: - -class PersistentContainer: NSPersistentContainer { +public class PersistentContainer: NSPersistentContainer { var storeURL: URL? { persistentStoreDescriptions.first?.url diff --git a/wire-ios-data-model/Source/Model/Conversation/ConversationLike.swift b/wire-ios-data-model/Source/Model/Conversation/ConversationLike.swift index a96569d3061..68802780a43 100644 --- a/wire-ios-data-model/Source/Model/Conversation/ConversationLike.swift +++ b/wire-ios-data-model/Source/Model/Conversation/ConversationLike.swift @@ -24,6 +24,10 @@ public typealias Conversation = ConversationLike & SwiftConversationLike // sourcery: AutoMockable @objc public protocol ConversationLike: AnyObject { + + // Any as type eraser to hide NSManagedObjectID behind it + var objectId: Any { get } + var conversationType: ZMConversationType { get } var isSelfAnActiveMember: Bool { get } var teamRemoteIdentifier: UUID? { get } @@ -70,6 +74,11 @@ public protocol SwiftConversationLike { } extension ZMConversation: ConversationLike { + + public var objectId: Any { + objectID + } + public var localParticipantsCount: Int { localParticipants.count } diff --git a/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift b/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift index a405d2b6d07..0a5adcf6d97 100644 --- a/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift +++ b/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift @@ -152,9 +152,6 @@ public protocol ZMConversationMessage: NSObjectProtocol { /// The replies quoting this message. var replies: Set { get } - /// An in-memory identifier for tracking the message during its life cycle. - var objectIdentifier: String { get } - /// The links attached to the message. var linkAttachments: [LinkAttachment]? { get set } @@ -245,10 +242,6 @@ extension ZMMessage: ZMConversationMessage { .sortedAscendingPrependingNil(by: \.serverTimestamp) } - public var objectIdentifier: String { - nonpersistedObjectIdentifer - } - public var causedSecurityLevelDegradation: Bool { false } diff --git a/wire-ios-data-model/Source/Model/User/UserType.swift b/wire-ios-data-model/Source/Model/User/UserType.swift index f52380bb875..97a15fad634 100644 --- a/wire-ios-data-model/Source/Model/User/UserType.swift +++ b/wire-ios-data-model/Source/Model/User/UserType.swift @@ -24,6 +24,9 @@ public protocol UserType: NSObjectProtocol, UserConnections { /// The identifier which uniquely idenitifies the user in its domain var remoteIdentifier: UUID! { get } + /// Any as type eraser to hide NSManagedObjectID behind it + var objectId: Any { get } + /// The domain which the user originates from var domain: String? { get } diff --git a/wire-ios-data-model/Source/Model/User/ZMSearchUser.swift b/wire-ios-data-model/Source/Model/User/ZMSearchUser.swift index 4f4476b430b..2431bc7d277 100644 --- a/wire-ios-data-model/Source/Model/User/ZMSearchUser.swift +++ b/wire-ios-data-model/Source/Model/User/ZMSearchUser.swift @@ -100,6 +100,9 @@ public class ZMSearchUser: NSObject, UserType { public var teamIdentifier: UUID? @objc public var user: ZMUser? public private(set) var hasDownloadedFullUserProfile: Bool = false + public var objectId: Any { + user?.objectId ?? remoteIdentifier! + } fileprivate weak var contextProvider: ContextProvider? private let searchUsersCache: SearchUsersCache? diff --git a/wire-ios-data-model/Source/Model/User/ZMUser.swift b/wire-ios-data-model/Source/Model/User/ZMUser.swift index e27d800317d..014778251c1 100644 --- a/wire-ios-data-model/Source/Model/User/ZMUser.swift +++ b/wire-ios-data-model/Source/Model/User/ZMUser.swift @@ -312,6 +312,10 @@ public extension ZMUser { } } + var objectId: Any { + objectID + } + /// combination of domain and remoteIdentifier @NSManaged private var primaryKey: String diff --git a/wire-ios-data-model/Source/Model/ZMManagedObject.m b/wire-ios-data-model/Source/Model/ZMManagedObject.m index 761d7d7f053..09451070212 100644 --- a/wire-ios-data-model/Source/Model/ZMManagedObject.m +++ b/wire-ios-data-model/Source/Model/ZMManagedObject.m @@ -768,33 +768,3 @@ - (BOOL)validateValue:(id *)ioValue forKey:(NSString *)key error:(NSError **)out } @end - - - - -@implementation ZMManagedObject (NonpersistedObjectIdentifer) - -- (NSString *)nonpersistedObjectIdentifer; -{ - return [NSString stringWithFormat:@"Z%tx", (unsigned long) self]; -} - -+ (instancetype)existingObjectWithNonpersistedObjectIdentifer:(NSString *)identifier inUserSession:(id)userSession; -{ - VerifyReturnNil(identifier != nil); - intptr_t value = 0; - if (sscanf([identifier UTF8String], "Z%tx", &value) != 1) { - return nil; - } - - NSManagedObjectContext *moc = userSession.viewContext; - for (ZMManagedObject *mo in moc.registeredObjects) { - intptr_t otherValue = (intptr_t) ((__bridge void *) mo); - if (otherValue == value) { - return mo; - } - } - return nil; -} - -@end diff --git a/wire-ios-data-model/Source/Public/ZMManagedObject.h b/wire-ios-data-model/Source/Public/ZMManagedObject.h index a5ca1e3446e..aadeabc2944 100644 --- a/wire-ios-data-model/Source/Public/ZMManagedObject.h +++ b/wire-ios-data-model/Source/Public/ZMManagedObject.h @@ -34,11 +34,3 @@ extern NSString * _Nonnull const ZMDataPropertySuffix; - (nullable NSString *)objectIDURLString; @end - -@interface ZMManagedObject (NonpersistedObjectIdentifer) - -@property (nonatomic, readonly, nonnull) NSString *nonpersistedObjectIdentifer; - -+ (nullable instancetype)existingObjectWithNonpersistedObjectIdentifer:(nullable NSString *)identifier inUserSession:(nonnull id)userSession; - -@end diff --git a/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift b/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift index 8f587f48907..a385da46fec 100644 --- a/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift +++ b/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift @@ -325,6 +325,15 @@ public class MockConversationLike: ConversationLike { public init() {} + // MARK: - objectId + + public var objectId: Any { + get { return underlyingObjectId } + set(value) { underlyingObjectId = value } + } + + public var underlyingObjectId: Any! + // MARK: - conversationType public var conversationType: ZMConversationType { @@ -2410,6 +2419,24 @@ public class MockCoreDataStackProtocol: CoreDataStackProtocol { mock(completionHandler) } + // MARK: - newBackgroundContext + + public var newBackgroundContext_Invocations: [Void] = [] + public var newBackgroundContext_MockMethod: (() -> NSManagedObjectContext)? + public var newBackgroundContext_MockValue: NSManagedObjectContext? + + public func newBackgroundContext() -> NSManagedObjectContext { + newBackgroundContext_Invocations.append(()) + + if let mock = newBackgroundContext_MockMethod { + return mock() + } else if let mock = newBackgroundContext_MockValue { + return mock + } else { + fatalError("no mock for `newBackgroundContext`") + } + } + } public class MockCryptoboxMigrationManagerInterface: CryptoboxMigrationManagerInterface { diff --git a/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.manual.swift b/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.manual.swift index 071cce29cf7..56f815c7d0d 100644 --- a/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.manual.swift +++ b/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.manual.swift @@ -210,15 +210,6 @@ public class MockZMConversationMessage: NSObject, ZMConversationMessage { public var underlyingReplies: Set! - // MARK: - objectIdentifier - - public var objectIdentifier: String { - get { return underlyingObjectIdentifier } - set(value) { underlyingObjectIdentifier = value } - } - - public var underlyingObjectIdentifier: String! - // MARK: - linkAttachments public var linkAttachments: [LinkAttachment]? diff --git a/wire-ios-data-model/Tests/Source/Model/Messages/ZMClientMessageTests+Editing.swift b/wire-ios-data-model/Tests/Source/Model/Messages/ZMClientMessageTests+Editing.swift index bc71bab05f4..8673aafe4b5 100644 --- a/wire-ios-data-model/Tests/Source/Model/Messages/ZMClientMessageTests+Editing.swift +++ b/wire-ios-data-model/Tests/Source/Model/Messages/ZMClientMessageTests+Editing.swift @@ -692,39 +692,4 @@ extension ZMClientMessageTests_Editing { XCTAssertEqual(editedMessage.textMessageData?.messageText, editedText) XCTAssertEqual(editedMessage, newMessage) } - - func testThatMessageNonPersistedIdentifierDoesNotChangeAfterEdit() { - // given - let oldText = "Mamma mia" - let newText = "here we go again" - let oldNonce = UUID.create() - - let sender = ZMUser.insertNewObject(in: uiMOC) - sender.remoteIdentifier = UUID.create() - - let conversation = ZMConversation.insertNewObject(in: uiMOC) - conversation.remoteIdentifier = UUID.create() - let message: ZMMessage = try! conversation.appendText(content: oldText) as! ZMMessage - message.sender = sender - message.nonce = oldNonce - - let oldIdentifier = message.nonpersistedObjectIdentifer - let updateEvent = createMessageEditUpdateEvent( - oldNonce: message.nonce!, - newNonce: UUID.create(), - conversationID: conversation.remoteIdentifier!, - senderID: message.sender!.remoteIdentifier!, - newText: newText - ) - - // when - var newMessage: ZMClientMessage? - performPretendingUiMocIsSyncMoc { - newMessage = ZMClientMessage.createOrUpdate(from: updateEvent!, in: self.uiMOC, prefetchResult: nil) - } - - // then - XCTAssertNotEqual(oldNonce, newMessage!.nonce) - XCTAssertEqual(oldIdentifier, newMessage!.nonpersistedObjectIdentifer) - } } diff --git a/wire-ios-data-model/Tests/Source/Model/ZMManagedObjectTests.m b/wire-ios-data-model/Tests/Source/Model/ZMManagedObjectTests.m index 1b4cbae2695..538f41e6d68 100644 --- a/wire-ios-data-model/Tests/Source/Model/ZMManagedObjectTests.m +++ b/wire-ios-data-model/Tests/Source/Model/ZMManagedObjectTests.m @@ -430,78 +430,6 @@ - (void)testThatDeletedObjectsAreZombiesAfterASave @implementation ZMManagedObjectTests (NonpersistedObjectIdentifer) -- (void)testThatItReturnsTheSameIdentifierForTemporaryAndSavedObjects; -{ - // given - ZMConversation *mo = [ZMConversation insertNewObjectInManagedObjectContext:self.uiMOC]; - - // when - NSString *s1 = [[mo nonpersistedObjectIdentifer] copy]; - XCTAssert([self.uiMOC saveOrRollback]); - NSString *s2 = [[mo nonpersistedObjectIdentifer] copy]; - - // then - XCTAssertNotNil(s1); - XCTAssertEqualObjects(s1, s2); -} - -- (void)testThatItReturnsAnObjectForANonpersistedObjectIdentifier -{ - // given - ZMConversation *mo = [ZMConversation insertNewObjectInManagedObjectContext:self.uiMOC]; - NSString *identifier = [[mo nonpersistedObjectIdentifer] copy]; - - // when - ZMConversation *mo2 = (id)[ZMManagedObject existingObjectWithNonpersistedObjectIdentifer:identifier inUserSession:self.coreDataStack]; - - // then - XCTAssertEqual(mo, mo2); -} - -- (void)testThatItReturnsAnObjectForANonpersistedObjectIdentifierAfterASave -{ - // given - ZMConversation *mo = [ZMConversation insertNewObjectInManagedObjectContext:self.uiMOC]; - NSString *identifier = [[mo nonpersistedObjectIdentifer] copy]; - - // when - XCTAssert([self.uiMOC saveOrRollback]); - ZMConversation *mo2 = (id)[ZMManagedObject existingObjectWithNonpersistedObjectIdentifer:identifier inUserSession:self.coreDataStack]; - - // then - XCTAssertEqual(mo, mo2); -} - -- (void)testThatItReturnsNilForANilIdentifier; -{ - // given - id objectIdentifier = nil; - - // then - [self performIgnoringZMLogError:^{ - XCTAssertNil([ZMManagedObject existingObjectWithNonpersistedObjectIdentifer:objectIdentifier inUserSession:self.coreDataStack]); - }]; -} - -- (void)testThatItReturnsNilForANonExistingIdentifier; -{ - // given - __block NSString *identifier; - [self.syncMOC performGroupedBlockAndWait:^{ - ZMConversation *mo = [ZMConversation insertNewObjectInManagedObjectContext:self.syncMOC]; - identifier = [[mo nonpersistedObjectIdentifer] copy]; - }]; - - // then - XCTAssertNil([ZMManagedObject existingObjectWithNonpersistedObjectIdentifer:identifier inUserSession:self.coreDataStack]); -} - -- (void)testThatItReturnsNilForAnInvalidExistingIdentifier; -{ - XCTAssertNil([ZMManagedObject existingObjectWithNonpersistedObjectIdentifer:@"foo" inUserSession:self.coreDataStack]); - XCTAssertNil([ZMManagedObject existingObjectWithNonpersistedObjectIdentifer:@"Zfoo" inUserSession:self.coreDataStack]); -} - - (void)testPerformanceRetrievingLocallyModifiedKeys; { // measured with NSSet implementation: average: 0.000, relative standard deviation: 33.239%, values: [0.000581, 0.000390, 0.000353, 0.000351, 0.000269, 0.000238, 0.000249, 0.000239, 0.000239, 0.000237], diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+UserSession.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+UserSession.swift index a7060d477b2..8b9167193c2 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+UserSession.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+UserSession.swift @@ -376,7 +376,6 @@ extension ZMUserSession: UserSession { isFederationUsageAllowed: isFederationUsageAllowed ) } - } extension UInt64 { diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift index a608241f6b8..e2c23a088cd 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift @@ -44,7 +44,7 @@ public final class ZMUserSession: NSObject { private(set) var isNetworkOnline = true - private(set) var coreDataStack: CoreDataStack! + public private(set) var coreDataStack: CoreDataStack! private let apiServiceFactory: APIServiceFactory public var apiService: APIServiceProtocol? { guard let clientId = selfUserClient?.remoteIdentifier else { @@ -1430,6 +1430,10 @@ extension ZMUserSession: ContextProvider { coreDataStack.viewContext } + public func newBackgroundContext() -> NSManagedObjectContext { + coreDataStack.newBackgroundContext() + } + public var syncContext: NSManagedObjectContext { coreDataStack.syncContext } diff --git a/wire-ios-sync-engine/Support/Sourcery/generated/AutoMockable.manual.swift b/wire-ios-sync-engine/Support/Sourcery/generated/AutoMockable.manual.swift index 4642b6d812b..891305367e0 100644 --- a/wire-ios-sync-engine/Support/Sourcery/generated/AutoMockable.manual.swift +++ b/wire-ios-sync-engine/Support/Sourcery/generated/AutoMockable.manual.swift @@ -1295,5 +1295,4 @@ public class MockUserSession: UserSession { fatalError("no mock for `e2eIdentityUpdateCertificateUpdateStatus`") } } - } diff --git a/wire-ios-utilities/Source/Public/SafeSubscript.swift b/wire-ios-utilities/Source/Public/SafeSubscript.swift new file mode 100644 index 00000000000..52db1a3d7a4 --- /dev/null +++ b/wire-ios-utilities/Source/Public/SafeSubscript.swift @@ -0,0 +1,26 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +public extension Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript(ifExists index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/wire-ios/Tests/Mocks/MockConversation.h b/wire-ios/Tests/Mocks/MockConversation.h index ec48e65e204..4c9f06011d3 100644 --- a/wire-ios/Tests/Mocks/MockConversation.h +++ b/wire-ios/Tests/Mocks/MockConversation.h @@ -23,6 +23,7 @@ NS_CLASS_DEPRECATED_IOS(4_0, 13_0, "Use SwiftMockConversation instead") @interface MockConversation : NSObject +@property (nonatomic, copy) id objectId; @property (nonatomic, copy) NSString *displayName; @property (nonatomic) id folder; @property (nonatomic) ZMUser *creator; diff --git a/wire-ios/Tests/Mocks/MockConversation.swift b/wire-ios/Tests/Mocks/MockConversation.swift index c5c07b5f740..4e9b0d0e230 100644 --- a/wire-ios/Tests/Mocks/MockConversation.swift +++ b/wire-ios/Tests/Mocks/MockConversation.swift @@ -24,6 +24,8 @@ import WireRequestStrategy // TODO: rename to MockConversation after objc MockConversation is retired class SwiftMockConversation: NSObject, Conversation { + var objectId: Any = UUID() + var isMLSConversationDegraded: Bool = false var isProteusConversationDegraded: Bool = false diff --git a/wire-ios/Tests/Mocks/MockMessage.swift b/wire-ios/Tests/Mocks/MockMessage.swift index 846f3ebe232..6d2d742e0c0 100644 --- a/wire-ios/Tests/Mocks/MockMessage.swift +++ b/wire-ios/Tests/Mocks/MockMessage.swift @@ -370,7 +370,6 @@ class MockMessage: NSObject, ZMConversationMessage, ConversationCompositeMessage var causedSecurityLevelDegradation: Bool = false var needsReadConfirmation: Bool = false - let objectIdentifier: String = UUID().uuidString var linkAttachments: [LinkAttachment]? var needsLinkAttachmentsUpdate: Bool = false var isSilenced: Bool = false diff --git a/wire-ios/Tests/Mocks/MockUserType.swift b/wire-ios/Tests/Mocks/MockUserType.swift index 889aaab5d72..f27f0da3a43 100644 --- a/wire-ios/Tests/Mocks/MockUserType.swift +++ b/wire-ios/Tests/Mocks/MockUserType.swift @@ -50,6 +50,9 @@ class MockUserType: NSObject, UserType, Decodable, EditableUserType { var teamIdentifier: UUID? var remoteIdentifier: UUID? + var objectId: Any { + remoteIdentifier ?? UUID() + } var canLeaveConversation = false var canCreateConversation = true diff --git a/wire-ios/Tests/Mocks/UserSessionMock.swift b/wire-ios/Tests/Mocks/UserSessionMock.swift index 456759a91fb..344be58951e 100644 --- a/wire-ios/Tests/Mocks/UserSessionMock.swift +++ b/wire-ios/Tests/Mocks/UserSessionMock.swift @@ -418,7 +418,6 @@ final class UserSessionMock: UserSession { var contextProvider: any ContextProvider { coreDataStack ?? MockContextProvider() } - } // MARK: - UserSessionMock + ContextProvider @@ -427,6 +426,10 @@ extension UserSessionMock: ContextProvider { var account: Account { contextProvider.account } var viewContext: NSManagedObjectContext { contextProvider.viewContext } + func newBackgroundContext() -> NSManagedObjectContext { + contextProvider.newBackgroundContext() + } + var syncContext: NSManagedObjectContext { contextProvider.syncContext } var searchContext: NSManagedObjectContext { contextProvider.searchContext } var eventContext: NSManagedObjectContext { contextProvider.eventContext } diff --git a/wire-ios/Tests/Sourcery/generated/AutoMockable.manual.swift b/wire-ios/Tests/Sourcery/generated/AutoMockable.manual.swift new file mode 100644 index 00000000000..ef0d1d7bf79 --- /dev/null +++ b/wire-ios/Tests/Sourcery/generated/AutoMockable.manual.swift @@ -0,0 +1,58 @@ +// Generated using Sourcery 2.2.4 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +// swiftlint:disable superfluous_disable_command +// swiftlint:disable vertical_whitespace +// swiftlint:disable line_length +// swiftlint:disable variable_name + + +import CoreLocation +import WireDataModel +import WireSyncEngine +import WireAccountImageUI + +@testable import Wire +@testable import WireCommonComponents + +class MockGetUserByIdUseCaseProtocol: GetUserByIDUseCaseProtocol { + + // MARK: - getUserByID + + var getUserByIdIdContext_Invocations: [(id: Any, context: NSManagedObjectContext)] = [] + var getUserByIdIdContext_MockValue: (any UserType)? + + func getUserByID(id: Any, context: NSManagedObjectContext) -> (any UserType)? { + getUserByIdIdContext_Invocations.append((id: id, context: context)) + + if let mock = getUserByIdIdContext_MockValue { + return mock + } else { + fatalError("no mock for `getUserByIdIdContext`") + } + } + +} + + +// swiftlint:enable variable_name +// swiftlint:enable line_length +// swiftlint:enable vertical_whitespace +// swiftlint:enable superfluous_disable_command diff --git a/wire-ios/Wire-iOS Tests/ConversationCell/StartedConversationCellTests.swift b/wire-ios/Wire-iOS Tests/ConversationCell/StartedConversationCellTests.swift index 694b609a184..14f4ee3ec3b 100644 --- a/wire-ios/Wire-iOS Tests/ConversationCell/StartedConversationCellTests.swift +++ b/wire-ios/Wire-iOS Tests/ConversationCell/StartedConversationCellTests.swift @@ -33,6 +33,8 @@ final class StartedConversationCellTests: ConversationMessageSnapshotTestCase { mockSelfUser = SelfUser.provider?.providedSelfUser as? MockUserType mockSelfUser.zmAccentColor = .blue + mockSelfUser.canAddUserToConversation = true + mockSelfUser.teamIdentifier = UUID() mockOtherUser = MockUserType.createDefaultOtherUser() } @@ -154,17 +156,19 @@ final class StartedConversationCellTests: ConversationMessageSnapshotTestCase { // MARK: - Invite Guests func testThatItRendersNewConversationCellWithParticipantsAndName_AllowGuests() { - + userSession.selfUser = mockSelfUser let message = cell(for: .newConversation, text: "Italy Trip", fillUsers: .many, allowGuests: true) verify(message: message) } func testThatItRendersNewConversationCellWithParticipantsAndWithoutName_AllowGuests() { + userSession.selfUser = mockSelfUser let message = cell(for: .newConversation, fillUsers: .many, allowGuests: true) verify(message: message) } func testThatItRendersNewConversationCellWithoutParticipants_AllowGuests() { + userSession.selfUser = mockSelfUser let message = cell(for: .newConversation, text: "Italy Trip", allowGuests: true) verify(message: message) } diff --git a/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSectionControllerTests.swift b/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSectionControllerTests.swift index c125e4a3c96..ddd3cf2adf3 100644 --- a/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSectionControllerTests.swift +++ b/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSectionControllerTests.swift @@ -64,13 +64,7 @@ final class ConversationMessageSectionControllerTests: XCTestCase { func testThatItReturnsCellsInCorrectOrder_Normal() { // GIVEN - let section = ConversationMessageSectionController( - message: MockMessage(), - context: context, - userSession: userSession, - useInvertedIndices: false, - contentWidth: 0 - ) + let section = makeSUT() section.cellDescriptionsForTesting.removeAll() // WHEN @@ -87,13 +81,7 @@ final class ConversationMessageSectionControllerTests: XCTestCase { func testThatItReturnsCellsInCorrectOrder_UpsideDown() { // GIVEN - let section = ConversationMessageSectionController( - message: MockMessage(), - context: context, - userSession: userSession, - useInvertedIndices: true, - contentWidth: 0 - ) + let section = makeSUT(useInvertedIndices: true) section.cellDescriptionsForTesting.removeAll() // WHEN @@ -114,13 +102,7 @@ final class ConversationMessageSectionControllerTests: XCTestCase { let context = ConversationMessageContext(isSameSenderAsPrevious: false) // When - let section = ConversationMessageSectionController( - message: message, - context: context, - userSession: userSession, - useInvertedIndices: false, - contentWidth: 0 - ) + let section = makeSUT(message: message, context: context) // Then let cellDescriptions = section.cellDescriptionsForTesting @@ -142,13 +124,7 @@ final class ConversationMessageSectionControllerTests: XCTestCase { ) // WHEN - let section = ConversationMessageSectionController( - message: message, - context: context, - userSession: userSession, - useInvertedIndices: false, - contentWidth: 0 - ) + let section = makeSUT(message: message, context: context) // THEN let cellDescriptions = section.cellDescriptionsForTesting @@ -166,13 +142,7 @@ final class ConversationMessageSectionControllerTests: XCTestCase { let context = ConversationMessageContext(previousMessageIsKnock: true) // When - let section = ConversationMessageSectionController( - message: message, - context: context, - userSession: userSession, - useInvertedIndices: false, - contentWidth: 0 - ) + let section = makeSUT(message: message, context: context) // Then let cellDescriptions = section.cellDescriptionsForTesting @@ -193,13 +163,7 @@ final class ConversationMessageSectionControllerTests: XCTestCase { isTimestampInSameMinuteAsPreviousMessage: false ) // WHEN - let section = ConversationMessageSectionController( - message: message, - context: context, - userSession: userSession, - useInvertedIndices: false, - contentWidth: 0 - ) + let section = makeSUT(message: message, context: context) // THEN let cellDescriptions = section.cellDescriptionsForTesting @@ -219,13 +183,7 @@ final class ConversationMessageSectionControllerTests: XCTestCase { isTimestampInSameMinuteAsPreviousMessage: false ) // WHEN - let section = ConversationMessageSectionController( - message: message, - context: context, - userSession: userSession, - useInvertedIndices: false, - contentWidth: 0 - ) + let section = makeSUT(message: message, context: context) let actionController = ConversationMessageActionController( responder: nil, @@ -323,17 +281,18 @@ final class ConversationMessageSectionControllerTests: XCTestCase { XCTAssertFalse(sut.isCollapsed) } - private func makeSUT(message: MockMessage) -> ConversationMessageSectionController { - let context = ConversationMessageContext( - isSameSenderAsPrevious: true, - isTimestampInSameMinuteAsPreviousMessage: false - ) + private func makeSUT( + message: MockMessage = MockMessage(), + context: ConversationMessageContext? = nil, + useInvertedIndices: Bool = false + ) -> ConversationMessageSectionController { // WHEN let section = ConversationMessageSectionController( message: message, - context: context, + context: context ?? self.context, + selfUser: mockSelfUser, userSession: userSession, - useInvertedIndices: false, + useInvertedIndices: useInvertedIndices, contentWidth: 0, userDefaults: mockUserDefaults ) diff --git a/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSnapshotTestCase.swift b/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSnapshotTestCase.swift index 0f9ca370ec0..60363c611ad 100644 --- a/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSnapshotTestCase.swift +++ b/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSnapshotTestCase.swift @@ -180,6 +180,7 @@ class ConversationMessageSnapshotTestCase: ZMSnapshotTestCase { let section = ConversationMessageSectionController( message: message, context: context, + selfUser: userSession.selfUser, userSession: userSession, useInvertedIndices: false, contentWidth: width, diff --git a/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationSenderMessageDetailsCellSnapshotTests.swift b/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationSenderMessageDetailsCellSnapshotTests.swift index 4337878a974..434b803d4ba 100644 --- a/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationSenderMessageDetailsCellSnapshotTests.swift +++ b/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationSenderMessageDetailsCellSnapshotTests.swift @@ -61,7 +61,7 @@ final class ConversationSenderMessageDetailsCellSnapshotTests: XCTestCase { // GIVEN mockUser.teamRole = .partner let configuration = ConversationSenderMessageDetailsCell.Configuration( - user: mockUser, + sender: mockUser, indicator: .none, teamRoleIndicator: .externalPartner ) @@ -77,7 +77,7 @@ final class ConversationSenderMessageDetailsCellSnapshotTests: XCTestCase { // GIVEN mockUser.isFederated = true let configuration = ConversationSenderMessageDetailsCell.Configuration( - user: mockUser, + sender: mockUser, indicator: .none, teamRoleIndicator: .federated ) @@ -93,7 +93,7 @@ final class ConversationSenderMessageDetailsCellSnapshotTests: XCTestCase { // GIVEN mockUser.isGuestInConversation = true let configuration = ConversationSenderMessageDetailsCell.Configuration( - user: mockUser, + sender: mockUser, indicator: .none, teamRoleIndicator: .guest ) @@ -109,7 +109,7 @@ final class ConversationSenderMessageDetailsCellSnapshotTests: XCTestCase { // GIVEN mockUser.mockedIsServiceUser = true let configuration = ConversationSenderMessageDetailsCell.Configuration( - user: mockUser, + sender: mockUser, indicator: .none, teamRoleIndicator: .service ) @@ -125,7 +125,7 @@ final class ConversationSenderMessageDetailsCellSnapshotTests: XCTestCase { // GIVEN mockUser.teamRole = .member let configuration = ConversationSenderMessageDetailsCell.Configuration( - user: mockUser, + sender: mockUser, indicator: .none, teamRoleIndicator: .none ) @@ -140,7 +140,7 @@ final class ConversationSenderMessageDetailsCellSnapshotTests: XCTestCase { func test_MessageHasBeenDeleted() { mockUser.teamRole = .member let configuration = ConversationSenderMessageDetailsCell.Configuration( - user: mockUser, + sender: mockUser, indicator: .deleted, teamRoleIndicator: .none ) @@ -160,7 +160,7 @@ final class ConversationSenderMessageDetailsCellSnapshotTests: XCTestCase { ) mockUser.isGuestInConversation = true let configuration = ConversationSenderMessageDetailsCell.Configuration( - user: mockUser, + sender: mockUser, indicator: .deleted, teamRoleIndicator: .guest ) @@ -177,7 +177,7 @@ final class ConversationSenderMessageDetailsCellSnapshotTests: XCTestCase { mockUser.name = nil mockUser.teamRole = .member let configuration = ConversationSenderMessageDetailsCell.Configuration( - user: mockUser, + sender: mockUser, indicator: .none, teamRoleIndicator: .none ) diff --git a/wire-ios/Wire-iOS Tests/ConversationReplyCellDescriptionTests.swift b/wire-ios/Wire-iOS Tests/ConversationReplyCellDescriptionTests.swift index 23c9a489475..34ce5095f1d 100644 --- a/wire-ios/Wire-iOS Tests/ConversationReplyCellDescriptionTests.swift +++ b/wire-ios/Wire-iOS Tests/ConversationReplyCellDescriptionTests.swift @@ -28,7 +28,7 @@ final class ConversationReplyCellDescriptionTests: CoreDataSnapshotTestCase { message.conversation = otherUserConversation // WHEN - let cellDescription = ConversationReplyCellDescription(quotedMessage: message) + let cellDescription = ConversationReplyCellDescription(quotedMessage: message, accentColor: .red) // THEN XCTAssertEqual(cellDescription.configuration.senderName, otherUser.name) @@ -41,7 +41,7 @@ final class ConversationReplyCellDescriptionTests: CoreDataSnapshotTestCase { message.conversation = otherUserConversation // WHEN - let cellDescription = ConversationReplyCellDescription(quotedMessage: message) + let cellDescription = ConversationReplyCellDescription(quotedMessage: message, accentColor: .red) // THEN XCTAssertEqual(cellDescription.configuration.senderName, "You") @@ -55,7 +55,7 @@ final class ConversationReplyCellDescriptionTests: CoreDataSnapshotTestCase { message.serverTimestamp = Date(timeIntervalSince1970: 1_497_798_000) // WHEN - let cellDescription = ConversationReplyCellDescription(quotedMessage: message) + let cellDescription = ConversationReplyCellDescription(quotedMessage: message, accentColor: .red) // THEN XCTAssertEqual(cellDescription.configuration.timestamp, "Original message from 6/18/17") @@ -69,7 +69,7 @@ final class ConversationReplyCellDescriptionTests: CoreDataSnapshotTestCase { message.serverTimestamp = .today(at: 9, 41) // WHEN - let cellDescription = ConversationReplyCellDescription(quotedMessage: message) + let cellDescription = ConversationReplyCellDescription(quotedMessage: message, accentColor: .red) // THEN XCTAssertEqual(cellDescription.configuration.timestamp, "Original message from 9:41 AM") diff --git a/wire-ios/Wire-iOS Tests/ConversationReplyCellTests.swift b/wire-ios/Wire-iOS Tests/ConversationReplyCellTests.swift index 7ed7c226651..7a983ec6c91 100644 --- a/wire-ios/Wire-iOS Tests/ConversationReplyCellTests.swift +++ b/wire-ios/Wire-iOS Tests/ConversationReplyCellTests.swift @@ -501,7 +501,7 @@ final class ConversationReplyCellTests: CoreDataSnapshotTestCase { // MARK: - Helpers private func makeCell(for message: ZMConversationMessage?) -> ConversationReplyCell { - let cellDescription = ConversationReplyCellDescription(quotedMessage: message) + let cellDescription = ConversationReplyCellDescription(quotedMessage: message, accentColor: .red) let cell = ConversationReplyCell() cell.configure(with: cellDescription.configuration, animated: false) XCTAssertTrue(waitForGroupsToBeEmpty([MediaAssetCache.defaultImageCache.dispatchGroup])) diff --git a/wire-ios/Wire-iOS Tests/ConversationViewControllerSnapshotTests.swift b/wire-ios/Wire-iOS Tests/ConversationViewControllerSnapshotTests.swift index c45985ccd7e..a006b16a3c0 100644 --- a/wire-ios/Wire-iOS Tests/ConversationViewControllerSnapshotTests.swift +++ b/wire-ios/Wire-iOS Tests/ConversationViewControllerSnapshotTests.swift @@ -196,6 +196,9 @@ extension ConversationViewControllerSnapshotTests { managedObjectContext: uiMOC, description: "all conversations" ) + userSession.coreDataStack?.newBackgroundContextProvider = { [uiMOC] in + uiMOC! + } sut = ConversationViewController( conversation: conversation, diff --git a/wire-ios/Wire-iOS Tests/DeviceManagement/MockContextProvider.swift b/wire-ios/Wire-iOS Tests/DeviceManagement/MockContextProvider.swift index 0f4ac80d98f..a45d615fe54 100644 --- a/wire-ios/Wire-iOS Tests/DeviceManagement/MockContextProvider.swift +++ b/wire-ios/Wire-iOS Tests/DeviceManagement/MockContextProvider.swift @@ -30,6 +30,10 @@ final class MockContextProvider: ContextProvider { NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) } + func newBackgroundContext() -> NSManagedObjectContext { + viewContext + } + var syncContext: NSManagedObjectContext { NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) } diff --git a/wire-ios/Wire-iOS Tests/Message+FormattingTests.swift b/wire-ios/Wire-iOS Tests/Message+FormattingTests.swift index 8838ffa1c72..3ad48cc4b17 100644 --- a/wire-ios/Wire-iOS Tests/Message+FormattingTests.swift +++ b/wire-ios/Wire-iOS Tests/Message+FormattingTests.swift @@ -59,7 +59,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "text text {preview-url}") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "text text") @@ -70,7 +70,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "text text {giphy-preview-url}") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "text text \(giphyURL)") @@ -81,7 +81,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "{giphy-preview-url}") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, giphyURL) @@ -92,7 +92,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "text text {regular-url} {preview-url}") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "text text \(regularURL)") @@ -103,7 +103,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "text text {preview-url} {regular-url}") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "text text \(previewURL) \(regularURL)") @@ -114,7 +114,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "{preview-url} text text") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "\(previewURL) text text") @@ -125,7 +125,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "{preview-url} {regular-url} text text") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "\(previewURL) \(regularURL) text text") @@ -136,7 +136,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "{regular-url} {preview-url} text text") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "\(regularURL) \(previewURL) text text") @@ -147,7 +147,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "{preview-url}") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "") @@ -160,7 +160,7 @@ class Message_FormattingTests: XCTestCase { // text // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "hello:") @@ -175,7 +175,7 @@ class Message_FormattingTests: XCTestCase { let mention = Mention(range: (textMessageData.messageText! as NSString).range(of: "@mention"), user: mockUser) textMessageData.mentions = [mention] - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "\(previewURL)@mention") @@ -194,7 +194,7 @@ class Message_FormattingTests: XCTestCase { let mention = Mention(range: (textMessageData.messageText! as NSString).range(of: "@mention"), user: mockUser) textMessageData.mentions = [mention] - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, "@mention\(previewURL) lala") @@ -215,7 +215,7 @@ class Message_FormattingTests: XCTestCase { let mention = Mention(range: NSRange(location: 57, length: 54), user: mockUser) textMessageData.mentions = [mention] - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual( @@ -233,7 +233,7 @@ class Message_FormattingTests: XCTestCase { let mention = Mention(range: NSRange(location: 19, length: 12), user: mockUser) textMessageData.mentions = [mention] - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual( @@ -247,7 +247,7 @@ class Message_FormattingTests: XCTestCase { let textMessageData = createTextMessageData(withMessageTemplate: "`:(`") // when - let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false) + let formattedText = NSAttributedString.format(message: textMessageData, isObfuscated: false, accentColor: .red) // then XCTAssertEqual(formattedText.string, ":(") diff --git a/wire-ios/Wire-iOS/Sources/AppDelegate.swift b/wire-ios/Wire-iOS/Sources/AppDelegate.swift index a741088aadd..651e1097c25 100644 --- a/wire-ios/Wire-iOS/Sources/AppDelegate.swift +++ b/wire-ios/Wire-iOS/Sources/AppDelegate.swift @@ -168,6 +168,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { self.launchOptions = launchOptions ?? [:] + _ = NSAttributedString.paragraphStyle + setupWindowAndRootViewController() if UIApplication.shared.isProtectedDataAvailable || ZMPersistentCookieStorage diff --git a/wire-ios/Wire-iOS/Sources/Helpers/UITraitEnvironment+LayoutMargins.swift b/wire-ios/Wire-iOS/Sources/Helpers/UITraitEnvironment+LayoutMargins.swift index f382d00d47e..873af887d4f 100644 --- a/wire-ios/Wire-iOS/Sources/Helpers/UITraitEnvironment+LayoutMargins.swift +++ b/wire-ios/Wire-iOS/Sources/Helpers/UITraitEnvironment+LayoutMargins.swift @@ -39,8 +39,7 @@ struct HorizontalMargins { } static func conversationHorizontalMargins( - windowWidth: CGFloat? = UIApplication.shared.delegate?.window??.frame - .width ?? UIScreen.main.bounds.width + windowWidth: CGFloat? = UIApplication.shared.delegate?.window??.frame.width ?? UIScreen.main.bounds.width ) -> HorizontalMargins { let userInterfaceSizeClass: UIUserInterfaceSizeClass diff --git a/wire-ios/Wire-iOS/Sources/Helpers/ZMConversationMessage+Announcement.swift b/wire-ios/Wire-iOS/Sources/Helpers/ZMConversationMessage+Announcement.swift index b15d2c3bab9..e5241949ff4 100644 --- a/wire-ios/Wire-iOS/Sources/Helpers/ZMConversationMessage+Announcement.swift +++ b/wire-ios/Wire-iOS/Sources/Helpers/ZMConversationMessage+Announcement.swift @@ -18,6 +18,7 @@ import Foundation import WireDataModel +import WireSyncEngine extension ZMConversationMessage { @@ -34,7 +35,11 @@ extension ZMConversationMessage { if isKnock { return ConversationAnnouncement.Ping.description(senderName) } else if isText, let textMessageData { - let messageText = NSAttributedString.format(message: textMessageData, isObfuscated: isObfuscated) + let messageText = NSAttributedString.format( + message: textMessageData, + isObfuscated: isObfuscated, + accentColor: .default + ) return "\(ConversationAnnouncement.Text.description(senderName)), \(messageText.string)" } else if isImage { return ConversationAnnouncement.Picture.description(senderName) @@ -47,7 +52,12 @@ extension ZMConversationMessage { } else if isFile { return ConversationAnnouncement.File.description(filename ?? "", senderName) } else if isSystem, let cellDescription = ConversationSystemMessageCellDescription.cells( - for: self, isCollapsed: true, buttonAction: nil + for: self, + isCollapsed: true, + buttonAction: nil, + selfUser: ZMUserSession.shared()!.selfUser, + accentColor: ZMAccentColor.default.accentColor.uiColor, + userSession: ZMUserSession.shared()! ).first { return cellDescription.cellAccessibilityLabel } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Components/MessagePreviewView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Components/MessagePreviewView.swift index 3b95bccdd4f..cb1168982e9 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Components/MessagePreviewView.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Components/MessagePreviewView.swift @@ -326,7 +326,11 @@ final class MessagePreviewView: UIView { if let textMessageData = message.textMessageData { contentTextView.attributedText = NSAttributedString.formatForPreview( message: textMessageData, - inputMode: true + inputMode: true, + accentColor: ( + ZMUserSession + .shared()?.selfUser.zmAccentColor ?? .default + ).accentColor ) } else if let location = message.locationMessageData { diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/BurstTimestampTableViewCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/BurstTimestampTableViewCell.swift index 4427a50fbf1..3fedaf06de9 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/BurstTimestampTableViewCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/BurstTimestampTableViewCell.swift @@ -139,7 +139,7 @@ final class BurstTimestampSenderMessageCellDescription: ConversationMessageCellD final class BurstTimestampSenderMessageCell: UIView, ConversationMessageCell { - struct Configuration { + struct Configuration: Equatable { let date: Date let isFirstMessageOfTheDay: Bool let showUnreadDot: Bool diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationCannotDecryptSystemMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationCannotDecryptSystemMessageCell.swift index f2f8798352d..8cdc77dbeca 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationCannotDecryptSystemMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationCannotDecryptSystemMessageCell.swift @@ -27,6 +27,7 @@ final class ConversationCannotDecryptSystemMessageCell: let icon: UIImage? let attributedText: NSAttributedString? let showLine: Bool + let accentColor: UIColor } var lastConfiguration: Configuration? diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationCannotDecryptSystemMessageCellDescription.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationCannotDecryptSystemMessageCellDescription.swift index 09a8ad994cf..b3801284721 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationCannotDecryptSystemMessageCellDescription.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationCannotDecryptSystemMessageCellDescription.swift @@ -40,7 +40,7 @@ final class ConversationCannotDecryptSystemMessageCellDescription: ConversationM let accessibilityIdentifier: String? = nil let accessibilityLabel: String? - init(message: ZMConversationMessage, data: ZMSystemMessageData, sender: UserType) { + init(message: ZMConversationMessage, data: ZMSystemMessageData, sender: UserType, accentColor: UIColor) { let icon: UIImage = if data.systemMessageType == .decryptionFailedResolved { StyleKitIcon.checkmark.makeImage( size: 16, @@ -55,13 +55,15 @@ final class ConversationCannotDecryptSystemMessageCellDescription: ConversationM let title = ConversationCannotDecryptSystemMessageCellDescription.makeAttributedString( systemMessage: data, - sender: sender + sender: sender, + accentColor: accentColor ) self.configuration = View.Configuration( icon: icon, attributedText: title, - showLine: false + showLine: false, + accentColor: accentColor ) self.accessibilityLabel = title.string @@ -82,11 +84,12 @@ final class ConversationCannotDecryptSystemMessageCellDescription: ConversationM private static func makeAttributedString( systemMessage: ZMSystemMessageData, - sender: UserType + sender: UserType, + accentColor: UIColor ) -> NSAttributedString { let messageString = messageString(systemMessage.systemMessageType, sender: sender) - let resetSessionString = resetSessionString() + let resetSessionString = resetSessionString(accentColor: accentColor) let errorDetailsString = errorDetailsString( errorCode: systemMessage.decryptionErrorCode?.intValue ?? 0, clientIdentifier: systemMessage.senderClientID ?? "N/A" @@ -150,14 +153,14 @@ final class ConversationCannotDecryptSystemMessageCellDescription: ConversationM return NSMutableAttributedString.markdown(from: localizationKey.localized(args: name), style: .systemMessage) } - private static func resetSessionString() -> NSAttributedString { + private static func resetSessionString(accentColor: UIColor) -> NSAttributedString { let string = L10n.Localizable.Content.System.CannotDecrypt.resetSession return NSAttributedString( string: string.localizedUppercase, attributes: [ .link: resetSessionURL, - .foregroundColor: UIColor.accent(), + .foregroundColor: accentColor, .font: UIFont.mediumSemiboldFont ] ) diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationIgnoredDeviceSystemMessageCellDescription.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationIgnoredDeviceSystemMessageCellDescription.swift index 40dab06dacf..bb633bf3477 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationIgnoredDeviceSystemMessageCellDescription.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationIgnoredDeviceSystemMessageCellDescription.swift @@ -38,7 +38,8 @@ final class ConversationIgnoredDeviceSystemMessageCellDescription: ConversationM init( message: ZMConversationMessage, data: ZMSystemMessageData, - user: UserType + user: UserType, + onUserTap: @escaping (_ userID: Any) -> Void ) { let title = ConversationIgnoredDeviceSystemMessageCellDescription.makeAttributedString( @@ -49,7 +50,7 @@ final class ConversationIgnoredDeviceSystemMessageCellDescription: ConversationM self.configuration = View.Configuration( attributedText: title, icon: WireStyleKit.imageOfShieldnotverified, - linkTarget: .user(user) + linkTarget: .user(user.objectId, onUserTap) ) self.accessibilityLabel = configuration.attributedText?.string diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationLegalHoldCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationLegalHoldCell.swift index 17a1de69ab7..50eab73a92f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationLegalHoldCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationLegalHoldCell.swift @@ -26,12 +26,12 @@ final class ConversationLegalHoldSystemMessageCell: ConversationIconBasedCell Void) + case conversation(_ id: Any, (_ id: Any) -> Void) } struct Configuration { @@ -70,15 +70,13 @@ final class ConversationNewDeviceSystemMessageCell< interaction: UITextItemInteraction ) -> Bool { - guard let linkTarget, - url == type(of: self).userClientURL, - let zClientViewController = ZClientViewController.shared else { return false } + guard let linkTarget else { return false } switch linkTarget { - case let .user(user): - zClientViewController.openClientListScreen(for: user) - case let .conversation(conversation): - zClientViewController.openDetailScreen(for: conversation) + case let .user(user, onUserTapped): + onUserTapped(user) + case let .conversation(conversation, onConversationTapped): + onConversationTapped(conversation) } return false diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationNewDeviceSystemMessageCellDescription.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationNewDeviceSystemMessageCellDescription.swift index 9dc92ec9134..bab76502236 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationNewDeviceSystemMessageCellDescription.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationNewDeviceSystemMessageCellDescription.swift @@ -27,7 +27,7 @@ final class ConversationNewDeviceSystemMessageCellDescription: ConversationMessa typealias View = ConversationNewDeviceSystemMessageCell typealias LabelColors = SemanticColors.Label - let configuration: View.Configuration + var configuration: View.Configuration var message: ZMConversationMessage? weak var delegate: ConversationMessageCellDelegate? @@ -41,11 +41,15 @@ final class ConversationNewDeviceSystemMessageCellDescription: ConversationMessa init( message: ZMConversationMessage, systemMessageData: ZMSystemMessageData, - conversation: ZMConversation + conversation: ConversationLike, + onUserTap: @escaping (_ userID: Any) -> Void, + onConversationTap: @escaping (_ conversationID: Any) -> Void ) { self.configuration = ConversationNewDeviceSystemMessageCellDescription.configuration( for: systemMessageData, - in: conversation + in: conversation, + onUserTap: onUserTap, + onConversationTap: onConversationTap ) self.accessibilityLabel = configuration.attributedText?.string self.actionController = nil @@ -65,7 +69,9 @@ final class ConversationNewDeviceSystemMessageCellDescription: ConversationMessa private static func configuration( for systemMessage: ZMSystemMessageData, - in conversation: ZMConversation + in conversation: ConversationLike, + onUserTap: @escaping (_ userID: Any) -> Void, + onConversationTap: @escaping (_ conversationID: Any) -> Void ) -> View.Configuration { let textAttributes = TextAttributes( @@ -82,15 +88,26 @@ final class ConversationNewDeviceSystemMessageCellDescription: ConversationMessa .sortedAscendingPrependingNil(by: \.name) if !systemMessage.addedUserTypes.isEmpty { - return configureForAddedUsers(in: conversation, attributes: textAttributes) + return configureForAddedUsers( + in: conversation, + attributes: textAttributes, + onConversationTap: onConversationTap + ) } else if users.count == 1, let user = users.first, user.isSelfUser { - return configureForNewClientOfSelfUser(user, clients: clients, link: View.userClientURL) + return configureForNewClientOfSelfUser( + user, + clients: clients, + link: View.userClientURL, + onUserTap: onUserTap + ) } else { return configureForOtherUsers( users, conversation: conversation, clients: clients, - attributes: textAttributes + attributes: textAttributes, + onUserTap: onUserTap, + onConversationTap: onConversationTap ) } } @@ -102,7 +119,8 @@ final class ConversationNewDeviceSystemMessageCellDescription: ConversationMessa private static func configureForNewClientOfSelfUser( _ selfUser: UserType, clients: [UserClientType], - link: URL + link: URL, + onUserTap: @escaping (_ userID: Any) -> Void ) -> View.Configuration { let string = L10n.Localizable.Content.System.selfUserNewClient(link.absoluteString) let attributedText = NSMutableAttributedString.markdown(from: string, style: .systemMessage) @@ -111,15 +129,17 @@ final class ConversationNewDeviceSystemMessageCellDescription: ConversationMessa return View.Configuration( attributedText: attributedText, icon: isSelfClient ? nil : verifiedIcon, - linkTarget: .user(selfUser) + linkTarget: .user(selfUser.objectId, onUserTap) ) } private static func configureForOtherUsers( _ users: [UserType], - conversation: ZMConversation, + conversation: ConversationLike, clients: [UserClientType], - attributes: TextAttributes + attributes: TextAttributes, + onUserTap: @escaping (_ userID: Any) -> Void, + onConversationTap: @escaping (_ conversationID: Any) -> Void ) -> View.Configuration { let displayNamesOfOthers = users.filter { !$0.isSelfUser }.compactMap(\.name) @@ -151,15 +171,19 @@ final class ConversationNewDeviceSystemMessageCellDescription: ConversationMessa let attributedText = attributedSenderNames let linkTarget: View.LinkTarget = if let user = users.first, users.count == 1 { - .user(user) + .user(user.objectId, onUserTap) } else { - .conversation(conversation) + .conversation(conversation.objectId, onConversationTap) } return View.Configuration(attributedText: attributedText, icon: verifiedIcon, linkTarget: linkTarget) } - private static func configureForAddedUsers(in conversation: ZMConversation, attributes: TextAttributes) -> View + private static func configureForAddedUsers( + in conversation: ConversationLike, + attributes: TextAttributes, + onConversationTap: @escaping (_ conversationID: Any) -> Void + ) -> View .Configuration { let attributedNewUsers = NSAttributedString( string: L10n.Localizable.Content.System.newUsers, @@ -175,7 +199,7 @@ final class ConversationNewDeviceSystemMessageCellDescription: ConversationMessa return View.Configuration( attributedText: attributedText, icon: verifiedIcon, - linkTarget: .conversation(conversation) + linkTarget: .conversation(conversation.objectId, onConversationTap) ) } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSenderMessageDetailsCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSenderMessageDetailsCell.swift index c749343618a..40151ca7ec3 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSenderMessageDetailsCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSenderMessageDetailsCell.swift @@ -40,7 +40,7 @@ enum TeamRoleIndicator { final class ConversationSenderMessageDetailsCell: UIView, ConversationMessageCell { struct Configuration { - let user: UserType + var sender: UserType let indicator: Indicator? let teamRoleIndicator: TeamRoleIndicator? } @@ -116,7 +116,7 @@ final class ConversationSenderMessageDetailsCell: UIView, ConversationMessageCel // MARK: - configure func configure(with object: Configuration, animated: Bool) { - let user = object.user + let user = object.sender avatar.user = user availabilityIndicatorView.availability = user.availability.mapToAccountImageAvailability() @@ -177,9 +177,10 @@ final class ConversationSenderMessageDetailsCell: UIView, ConversationMessageCel } private func configureAuthorLabel(object: Configuration) { - let textColor: UIColor = object.user.isServiceUser ? SemanticColors.Label.textDefault : object.user.accentColor + let sender = object.sender + let textColor: UIColor = sender.isServiceUser ? SemanticColors.Label.textDefault : sender.accentColor let attributedString = NSMutableAttributedString( - string: object.user.name ?? L10n.Localizable.Profile.Details.Title.unavailable, + string: sender.name ?? L10n.Localizable.Profile.Details.Title.unavailable, attributes: [ .foregroundColor: textColor, .font: UIFont.mediumSemiboldFont @@ -278,9 +279,16 @@ final class ConversationSenderMessageCellDescription: ConversationMessageCellDes typealias View = ConversationSenderMessageDetailsCell typealias ConversationAnnouncement = L10n.Accessibility.ConversationAnnouncement - let configuration: View.Configuration + var configuration: View.Configuration + + var message: ZMConversationMessage? { + didSet { + if let sender = message?.senderUser { + configuration.sender = sender + } + } + } - var message: ZMConversationMessage? weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? @@ -296,29 +304,32 @@ final class ConversationSenderMessageCellDescription: ConversationMessageCellDes /// - timestamp: The given timestamp of the message init( sender: UserType, + selfUser: any UserType, message: ZMConversationMessage ) { self.message = message - - let teamRoleIndicator = sender.teamRoleIndicator() + let teamRoleIndicator = sender.teamRoleIndicator(selfUser: selfUser) let indicator: Indicator? = if message.isDeletion { .deleted } else { .none } self.configuration = View.Configuration( - user: sender, + sender: sender, indicator: indicator, teamRoleIndicator: teamRoleIndicator ) - setupAccessibility(sender) + setupAccessibility(sender, selfUser: selfUser) self.actionController = nil } // MARK: - Accessibility - private func setupAccessibility(_ sender: UserType) { + private func setupAccessibility( + _ sender: UserType, + selfUser: (any UserType)? + ) { guard let message, let senderName = sender.name else { accessibilityLabel = nil return @@ -329,7 +340,8 @@ final class ConversationSenderMessageCellDescription: ConversationMessageCellDes if message.isText, let textMessageData = message.textMessageData { let messageText = NSAttributedString.format( message: textMessageData, - isObfuscated: message.isObfuscated + isObfuscated: message.isObfuscated, + accentColor: (selfUser?.zmAccentColor ?? .default).accentColor ) accessibilityLabel = ConversationAnnouncement.EditedMessage.description(senderName) + messageText.string } else { @@ -343,7 +355,8 @@ final class ConversationSenderMessageCellDescription: ConversationMessageCellDes } private extension UserType { - func teamRoleIndicator(with provider: SelfUserProvider? = SelfUser.provider) -> TeamRoleIndicator? { + + func teamRoleIndicator(selfUser: any UserType) -> TeamRoleIndicator? { if isServiceUser { .service @@ -353,9 +366,7 @@ private extension UserType { } else if isFederated { .federated - } else if !isTeamMember, - let selfUser = provider?.providedSelfUser, - selfUser.isTeamMember { + } else if !isTeamMember, selfUser.isTeamMember { .guest } else { nil diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationStartedSystemMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationStartedSystemMessageCell.swift index 13dca49d6aa..42eb4d16db6 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationStartedSystemMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationStartedSystemMessageCell.swift @@ -26,7 +26,7 @@ final class ConversationStartedSystemMessageCell< struct Configuration { let title: NSAttributedString? let message: NSAttributedString - let selectedUsers: [UserType] + var selectedUsers: [UserType] let icon: UIImage? } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationStartedSystemMessageCellDescription.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationStartedSystemMessageCellDescription.swift index c1d1917108a..c5b5f54b57f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationStartedSystemMessageCellDescription.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationStartedSystemMessageCellDescription.swift @@ -26,9 +26,16 @@ final class ConversationStartedSystemMessageCellDescription: NSObject, Conversat typealias IconColors = SemanticColors.Icon typealias LabelColors = SemanticColors.Label - let configuration: View.Configuration + var configuration: View.Configuration + + var message: ZMConversationMessage? { + didSet { + if let message { + configuration.selectedUsers = Self.makeModel(message: message).selectedUsers + } + } + } - var message: ZMConversationMessage? weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? @@ -40,27 +47,35 @@ final class ConversationStartedSystemMessageCellDescription: NSObject, Conversat let accessibilityIdentifier: String? = nil var conversationObserverToken: Any? - init(message: ZMConversationMessage, data: ZMSystemMessageData) { + init(message: ZMConversationMessage) { + self.configuration = Self.makeConfiguration(message: message) + self.actionController = nil + + super.init() + + accessibilityLabel = configuration.message.string + } + + private static func makeModel(message: ZMConversationMessage) -> ParticipantsCellViewModel { let color = LabelColors.textDefault let iconColor = IconColors.backgroundDefault - let model = ParticipantsCellViewModel( + return ParticipantsCellViewModel( font: .mediumFont, largeFont: .largeSemiboldFont, textColor: color, iconColor: iconColor, message: message ) + } - self.actionController = nil - self.configuration = View.Configuration( + private static func makeConfiguration(message: ZMConversationMessage) -> View.Configuration { + let model = makeModel(message: message) + return View.Configuration( title: model.attributedHeading(), message: model.attributedTitle() ?? NSAttributedString(string: ""), selectedUsers: model.selectedUsers, icon: model.image() ) - super.init() - - accessibilityLabel = configuration.message.string } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSystemMessageCellDescription.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSystemMessageCellDescription.swift index c3696824445..cd240b7940f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSystemMessageCellDescription.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSystemMessageCellDescription.swift @@ -18,13 +18,17 @@ import UIKit import WireDataModel +import WireSyncEngine enum ConversationSystemMessageCellDescription { static func cells( for message: ZMConversationMessage, isCollapsed: Bool, - buttonAction: Completion? + buttonAction: Completion?, + selfUser: any UserType, + accentColor: UIColor, + userSession: UserSession ) -> [AnyConversationMessageCellDescription] { guard let systemMessageData = message.systemMessageData, @@ -63,7 +67,11 @@ enum ConversationSystemMessageCellDescription { return [] case .messageDeletedForEveryone: - let senderCell = ConversationSenderMessageCellDescription(sender: sender, message: message) + let senderCell = ConversationSenderMessageCellDescription( + sender: sender, + selfUser: selfUser, + message: message + ) return [AnyConversationMessageCellDescription(senderCell)] case .messageTimerUpdate: @@ -103,7 +111,8 @@ enum ConversationSystemMessageCellDescription { let decryptionCell = ConversationCannotDecryptSystemMessageCellDescription( message: message, data: systemMessageData, - sender: sender + sender: sender, + accentColor: accentColor ) return [AnyConversationMessageCellDescription(decryptionCell)] @@ -111,7 +120,13 @@ enum ConversationSystemMessageCellDescription { let newClientCell = ConversationNewDeviceSystemMessageCellDescription( message: message, systemMessageData: systemMessageData, - conversation: conversation as! ZMConversation + conversation: conversation, + onUserTap: { userID in + showUser(id: userID, userSession: userSession) + }, + onConversationTap: { conversationID in + showConversation(id: conversationID, userSession: userSession) + } ) return [AnyConversationMessageCellDescription(newClientCell)] @@ -120,7 +135,10 @@ enum ConversationSystemMessageCellDescription { let ignoredClientCell = ConversationIgnoredDeviceSystemMessageCellDescription( message: message, data: systemMessageData, - user: user + user: user, + onUserTap: { userID in + showUser(id: userID, userSession: userSession) + } ) return [AnyConversationMessageCellDescription(ignoredClientCell)] @@ -156,16 +174,12 @@ enum ConversationSystemMessageCellDescription { case .newConversation: var cells: [AnyConversationMessageCellDescription] = [] - let startedConversationCell = ConversationStartedSystemMessageCellDescription( - message: message, - data: systemMessageData - ) + let startedConversationCell = ConversationStartedSystemMessageCellDescription(message: message) cells.append(AnyConversationMessageCellDescription(startedConversationCell)) // Only display invite user cell for team members - if let user = SelfUser.provider?.providedSelfUser, - user.isTeamMember, - conversation.selfCanAddUsers, + if selfUser.isTeamMember, + conversation.selfCanAddUsers(selfUser: selfUser), conversation.isOpenGroup { cells.append( AnyConversationMessageCellDescription( @@ -219,6 +233,33 @@ enum ConversationSystemMessageCellDescription { return [] } + + private static func showUser( + id: Any, + userSession: UserSession + ) { + guard let managedId = id as? NSManagedObjectID, + let zClientViewController = ZClientViewController.shared, + let user = ZMUser.existingObject(with: managedId, inUserSession: userSession.contextProvider) else { + return + } + zClientViewController.openClientListScreen(for: user) + } + + private static func showConversation( + id: Any, + userSession: UserSession + ) { + guard let managedId = id as? NSManagedObjectID, + let zClientViewController = ZClientViewController.shared, + let conversation = ZMConversation.existingObject( + with: managedId, + inUserSession: userSession.contextProvider + ) else { + return + } + zClientViewController.openDetailScreen(for: conversation) + } } private extension ConversationLike { @@ -226,8 +267,8 @@ private extension ConversationLike { conversationType == .group && allowGuests } - var selfCanAddUsers: Bool { - guard let user = SelfUser.provider?.providedSelfUser else { + func selfCanAddUsers(selfUser: (any UserType)?) -> Bool { + guard let user = selfUser else { assertionFailure("expected available 'user'!") return false } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationWarningSystemMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationWarningSystemMessageCell.swift index 0ac22546d24..51c385284dd 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationWarningSystemMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationWarningSystemMessageCell.swift @@ -27,7 +27,7 @@ final class ConversationWarningSystemMessageCell< private typealias LabelColors = SemanticColors.Label private typealias IconColors = SemanticColors.Icon - struct Configuration { + struct Configuration: Equatable { let topText: String let bottomText: String } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationAudioMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationAudioMessageCell.swift index e351ef6b2ee..71d38179e73 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationAudioMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationAudioMessageCell.swift @@ -22,11 +22,15 @@ import WireDesign final class ConversationAudioMessageCell: UIView, ConversationMessageCell { - struct Configuration { - let message: ZMConversationMessage - var isObfuscated: Bool { - message.isObfuscated + struct Configuration: Equatable { + var message: ZMConversationMessage + var isObfuscated: Bool + + static func == (lhs: Configuration, rhs: Configuration) -> Bool { + lhs.message == rhs.message && + lhs.isObfuscated == rhs.isObfuscated } + } private var containerView = RoundedView() @@ -124,12 +128,19 @@ extension ConversationAudioMessageCell: TransferViewDelegate { final class ConversationAudioMessageCellDescription: ConversationMessageCellDescription { typealias View = ConversationAudioMessageCell - let configuration: View.Configuration + var configuration: View.Configuration let supportsActions: Bool = true let containsHighlightableContent: Bool = true - weak var message: ZMConversationMessage? + weak var message: ZMConversationMessage? { + didSet { + if let message { + configuration.message = message + } + } + } + weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? @@ -140,7 +151,8 @@ final class ConversationAudioMessageCellDescription: ConversationMessageCellDesc let accessibilityLabel: String? init(message: ZMConversationMessage) { - self.configuration = View.Configuration(message: message) + self.configuration = View + .Configuration(message: message, isObfuscated: message.isObfuscated) self.accessibilityLabel = L10n.Accessibility.ConversationSearch.AudioMessage.description } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationImageMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationImageMessageCell.swift index 3959ee88da8..a0b5a6ba354 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationImageMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationImageMessageCell.swift @@ -22,12 +22,17 @@ import WireDesign final class ConversationImageMessageCell: UIView, ConversationMessageCell, ContextMenuDelegate { - struct Configuration { - let image: ZMImageMessageData - let message: ZMConversationMessage - var isObfuscated: Bool { - message.isObfuscated + struct Configuration: Equatable { + var image: ZMImageMessageData + var message: ZMConversationMessage + var isObfuscated: Bool + + static func == (lhs: Configuration, rhs: Configuration) -> Bool { + lhs.message == rhs.message && + lhs.image.imageDataIdentifier == rhs.image.imageDataIdentifier && + lhs.isObfuscated == rhs.isObfuscated } + } private var containerView = UIView() @@ -169,9 +174,17 @@ final class ConversationImageMessageCell: UIView, ConversationMessageCell, Conte final class ConversationImageMessageCellDescription: ConversationMessageCellDescription { typealias View = ConversationImageMessageCell - let configuration: View.Configuration + var configuration: View.Configuration + + var message: ZMConversationMessage? { + didSet { + if let message { + configuration.message = message + configuration.image = message.imageMessageData! + } + } + } - var message: ZMConversationMessage? weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? @@ -187,7 +200,12 @@ final class ConversationImageMessageCellDescription: ConversationMessageCellDesc init(message: ZMConversationMessage, image: ZMImageMessageData) { self.message = message - self.configuration = View.Configuration(image: image, message: message) + self.configuration = View + .Configuration( + image: image, + message: message, + isObfuscated: message.isObfuscated + ) self.accessibilityLabel = L10n.Accessibility.ConversationSearch.ImageMessage.description } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationLocationMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationLocationMessageCell.swift index 4dc95ae9bfb..637de67a13f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationLocationMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationLocationMessageCell.swift @@ -23,12 +23,17 @@ import WireDesign final class ConversationLocationMessageCell: UIView, ConversationMessageCell, ContextMenuDelegate { - struct Configuration { - let location: LocationMessageData - let message: ZMConversationMessage - var isObfuscated: Bool { - message.isObfuscated + struct Configuration: Equatable { + var location: LocationMessageData + var message: ZMConversationMessage + var isObfuscated: Bool + + static func == (lhs: Configuration, rhs: Configuration) -> Bool { + lhs.message == rhs.message && + lhs.message == rhs.message && + lhs.isObfuscated == rhs.isObfuscated } + } private var lastConfiguration: Configuration? @@ -189,9 +194,17 @@ final class ConversationLocationMessageCell: UIView, ConversationMessageCell, Co final class ConversationLocationMessageCellDescription: ConversationMessageCellDescription { typealias View = ConversationLocationMessageCell - let configuration: View.Configuration + var configuration: View.Configuration + + var message: ZMConversationMessage? { + didSet { + if let message, let locationMessageData = message.locationMessageData { + configuration.location = locationMessageData + configuration.isObfuscated = message.isObfuscated + } + } + } - var message: ZMConversationMessage? weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? @@ -205,6 +218,11 @@ final class ConversationLocationMessageCellDescription: ConversationMessageCellD let accessibilityLabel: String? = nil init(message: ZMConversationMessage, location: LocationMessageData) { - self.configuration = View.Configuration(location: location, message: message) + self.configuration = View + .Configuration( + location: location, + message: message, + isObfuscated: message.isObfuscated + ) } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationPingCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationPingCell.swift index 505c1fe74a5..524795f9639 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationPingCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Rich Content/ConversationPingCell.swift @@ -28,10 +28,17 @@ final class ConversationPingCell: ConversationIconBasedCell Bool { + lhs.message == rhs.message && + lhs.pingColor == rhs.pingColor && + lhs.pingText == rhs.pingText + } + } func configure(with object: Configuration, animated: Bool) { @@ -130,9 +137,16 @@ final class ConversationPingCell: ConversationIconBasedCell Bool { + lhs.message == rhs.message && + lhs.isObfuscated == rhs.isObfuscated } + } private var containerView = RoundedView() @@ -139,12 +143,19 @@ extension ConversationVideoMessageCell: TransferViewDelegate { final class ConversationVideoMessageCellDescription: ConversationMessageCellDescription { typealias View = ConversationVideoMessageCell - let configuration: View.Configuration + var configuration: View.Configuration let supportsActions: Bool = true let containsHighlightableContent: Bool = true - weak var message: ZMConversationMessage? + weak var message: ZMConversationMessage? { + didSet { + if let message { + configuration.message = message + } + } + } + weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? @@ -155,7 +166,8 @@ final class ConversationVideoMessageCellDescription: ConversationMessageCellDesc let accessibilityLabel: String? init(message: ZMConversationMessage) { - self.configuration = View.Configuration(message: message) + self.configuration = View + .Configuration(message: message, isObfuscated: message.isObfuscated) self.accessibilityLabel = L10n.Accessibility.ConversationSearch.VideoMessage.description } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationCollapsedMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationCollapsedMessageCell.swift index a66bc0db341..f6e1ae3b8ec 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationCollapsedMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationCollapsedMessageCell.swift @@ -18,14 +18,20 @@ import WireAccountImageUI import WireDesign +import WireFoundation import WireSyncEngine final class ConversationCollapsedMessageCell: UIView, ConversationMessageCell { - struct Configuration { - let message: ZMConversationMessage - let user: UserType? + struct Configuration: Equatable { + var message: ZMConversationMessage + let accentColor: AccentColor let collapseExpandAction: () -> Void + + static func == (lhs: Configuration, rhs: Configuration) -> Bool { + lhs.message == rhs.message && + lhs.accentColor == rhs.accentColor + } } var isSelected: Bool = false @@ -130,7 +136,7 @@ final class ConversationCollapsedMessageCell: UIView, ConversationMessageCell { } func configure(with object: Configuration, animated: Bool) { - let user = object.user + let user = object.message.senderUser avatar.user = user availabilityIndicatorView.availability = user?.availability.mapToAccountImageAvailability() @@ -145,7 +151,8 @@ final class ConversationCollapsedMessageCell: UIView, ConversationMessageCell { messageTextView.attributedText = NSAttributedString .format( message: textMessageData, - isObfuscated: message.isObfuscated + isObfuscated: message.isObfuscated, + accentColor: object.accentColor ) } } else { @@ -250,12 +257,19 @@ final class ConversationCollapsedMessageCellDescription: ConversationMessageCell typealias View = ConversationCollapsedMessageCell - let configuration: View.Configuration + var configuration: View.Configuration let supportsActions: Bool = true let containsHighlightableContent: Bool = false - weak var message: ZMConversationMessage? + weak var message: ZMConversationMessage? { + didSet { + if let message { + configuration.message = message + } + } + } + weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? @@ -267,11 +281,12 @@ final class ConversationCollapsedMessageCellDescription: ConversationMessageCell init( message: ConversationMessage, + accentColor: AccentColor, collapseExpandAction: @escaping () -> Void ) { self.configuration = View.Configuration( message: message, - user: message.senderUser, + accentColor: accentColor, collapseExpandAction: collapseExpandAction ) } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationFileMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationFileMessageCell.swift index 76d83a93879..c7863bfaa59 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationFileMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationFileMessageCell.swift @@ -22,10 +22,13 @@ import WireDesign final class ConversationFileMessageCell: UIView, ConversationMessageCell { - struct Configuration { - let message: ZMConversationMessage - var isObfuscated: Bool { - message.isObfuscated + struct Configuration: Equatable { + var message: ZMConversationMessage + var isObfuscated: Bool + + static func == (lhs: Configuration, rhs: Configuration) -> Bool { + lhs.message == rhs.message && + lhs.isObfuscated == rhs.isObfuscated } } @@ -126,12 +129,19 @@ extension ConversationFileMessageCell: TransferViewDelegate { final class ConversationFileMessageCellDescription: ConversationMessageCellDescription { typealias View = ConversationFileMessageCell - let configuration: View.Configuration + var configuration: View.Configuration let supportsActions: Bool = true let containsHighlightableContent: Bool = true - weak var message: ZMConversationMessage? + weak var message: ZMConversationMessage? { + didSet { + if let message { + configuration.message = message + } + } + } + weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? @@ -143,7 +153,7 @@ final class ConversationFileMessageCellDescription: ConversationMessageCellDescr init(message: ZMConversationMessage) { self.configuration = View - .Configuration(message: message) + .Configuration(message: message, isObfuscated: message.isObfuscated) } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationLinkAttachmentCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationLinkAttachmentCell.swift index 57e68b327d6..36e69036d6d 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationLinkAttachmentCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationLinkAttachmentCell.swift @@ -23,9 +23,14 @@ import WireDesign final class ConversationLinkAttachmentCell: UIView, ConversationMessageCell, HighlightableView, ContextMenuDelegate { - struct Configuration { - let attachment: LinkAttachment - let thumbnailResource: WireImageResource? + struct Configuration: Equatable { + var attachment: LinkAttachment + var thumbnailResource: WireImageResource? + + static func == (lhs: Configuration, rhs: Configuration) -> Bool { + lhs.attachment == rhs.attachment && + lhs.thumbnailResource?.cacheIdentifier == rhs.thumbnailResource?.cacheIdentifier + } } lazy var attachmentView: MediaPreviewView = { @@ -147,9 +152,17 @@ extension ConversationLinkAttachmentCell: LinkViewDelegate { final class ConversationLinkAttachmentCellDescription: ConversationMessageCellDescription { typealias View = ConversationLinkAttachmentCell - let configuration: View.Configuration + var configuration: View.Configuration + + weak var message: ZMConversationMessage? { + didSet { + if let message, let attachment = message.linkAttachments?.first { + configuration.thumbnailResource = message.linkAttachmentImage + configuration.attachment = attachment + } + } + } - weak var message: ZMConversationMessage? weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationLinkPreviewArticleCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationLinkPreviewArticleCell.swift index 4e740368bc0..9e9ba19f405 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationLinkPreviewArticleCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationLinkPreviewArticleCell.swift @@ -21,13 +21,19 @@ import WireDataModel final class ConversationLinkPreviewArticleCell: UIView, ConversationMessageCell, ContextMenuDelegate { - struct Configuration { - let textMessageData: TextMessageData + struct Configuration: Equatable { + var textMessageData: TextMessageData let showImage: Bool - let message: ZMConversationMessage - var isObfuscated: Bool { - message.isObfuscated + var message: ZMConversationMessage + var isObfuscated: Bool + + static func == (lhs: Configuration, rhs: Configuration) -> Bool { + lhs.message == rhs.message && + lhs.showImage == rhs.showImage && + lhs.isObfuscated == rhs.isObfuscated && + lhs.textMessageData.messageText == rhs.textMessageData.messageText } + } private let articleView = ArticleView(withImagePlaceholder: true) @@ -101,9 +107,17 @@ extension ConversationLinkPreviewArticleCell: LinkViewDelegate { final class ConversationLinkPreviewArticleCellDescription: ConversationMessageCellDescription { typealias View = ConversationLinkPreviewArticleCell - let configuration: View.Configuration + var configuration: View.Configuration + + weak var message: ZMConversationMessage? { + didSet { + if let message { + configuration.message = message + configuration.textMessageData = message.textMessageData! + } + } + } - weak var message: ZMConversationMessage? weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? @@ -121,7 +135,13 @@ final class ConversationLinkPreviewArticleCellDescription: ConversationMessageCe init(message: ZMConversationMessage, data: TextMessageData) { let showImage = data.linkPreviewHasImage - self.configuration = View.Configuration(textMessageData: data, showImage: showImage, message: message) + self.configuration = View + .Configuration( + textMessageData: data, + showImage: showImage, + message: message, + isObfuscated: message.isObfuscated + ) self.accessibilityLabel = L10n.Accessibility.ConversationSearch.LinkMessage.description } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationQuoteCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationQuoteCell.swift index 0d2e232428b..63c71d5fb6e 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationQuoteCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationQuoteCell.swift @@ -21,19 +21,26 @@ import UIKit import WireCommonComponents import WireDataModel import WireDesign +import WireFoundation final class ConversationReplyContentView: UIView { typealias FileSharingRestrictions = L10n.Localizable.FeatureConfig.FileSharingRestrictions typealias MessagePreview = L10n.Localizable.Conversation.InputBar.MessagePreview let numberOfLinesLimit: Int = 4 - struct Configuration { + struct Configuration: Equatable { enum Content { case text(NSAttributedString) case imagePreview(thumbnail: PreviewableImageResource, isVideo: Bool) } var quotedMessage: ZMConversationMessage? + let accentColor: AccentColor + + static func == (lhs: Configuration, rhs: Configuration) -> Bool { + lhs.accentColor == rhs.accentColor && + lhs.quotedMessage == rhs.quotedMessage + } var showDetails: Bool { guard let message = quotedMessage, @@ -107,7 +114,14 @@ final class ConversationReplyContentView: UIView { switch quotedMessage { case let message? where message.isText: let data = message.textMessageData! - return .text(NSAttributedString.formatForPreview(message: data, inputMode: false)) + return .text( + NSAttributedString + .formatForPreview( + message: data, + inputMode: false, + accentColor: accentColor + ) + ) case let message? where message.isLocation: let location = message.locationMessageData! @@ -326,9 +340,10 @@ final class ConversationReplyCell: UIView, ConversationMessageCell { } final class ConversationReplyCellDescription: ConversationMessageCellDescription { + typealias View = ConversationReplyCell - let configuration: View.Configuration + var configuration: View.Configuration var topMargin: CGFloat = 8 var bottomMargin: CGFloat = 0 @@ -336,15 +351,26 @@ final class ConversationReplyCellDescription: ConversationMessageCellDescription let supportsActions = false let containsHighlightableContent: Bool = true - weak var message: ZMConversationMessage? + weak var message: ZMConversationMessage? { + didSet { + if let quoteMessage = message?.textMessageData?.quoteMessage { + configuration.quotedMessage = quoteMessage + } + } + } + weak var delegate: ConversationMessageCellDelegate? weak var actionController: ConversationMessageActionController? let accessibilityLabel: String? = L10n.Localizable.Content.Message.originalLabel let accessibilityIdentifier: String? = "ReplyCell" - init(quotedMessage: ZMConversationMessage?) { - self.configuration = View.Configuration(quotedMessage: quotedMessage) + init(quotedMessage: ZMConversationMessage?, accentColor: AccentColor) { + self.configuration = View + .Configuration( + quotedMessage: quotedMessage, + accentColor: accentColor + ) } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationTextMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationTextMessageCell.swift index cf99838da08..c51271b7141 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationTextMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Content/Text/ConversationTextMessageCell.swift @@ -168,7 +168,9 @@ extension ConversationTextMessageCellDescription { static func cells( for message: ZMConversationMessage, - searchQueries: [String] + searchQueries: [String], + selfUser: any UserType, + userSession: UserSession ) -> [AnyConversationMessageCellDescription] { guard let textMessageData = message.textMessageData else { preconditionFailure("Invalid text message") @@ -177,28 +179,40 @@ extension ConversationTextMessageCellDescription { return cells( textMessageData: textMessageData, message: message, - searchQueries: searchQueries + searchQueries: searchQueries, + selfUser: selfUser, + userSession: userSession ) } static func cells( textMessageData: TextMessageData, message: ZMConversationMessage, - searchQueries: [String] + searchQueries: [String], + selfUser: any UserType, + userSession: UserSession ) -> [AnyConversationMessageCellDescription] { var cells: [AnyConversationMessageCellDescription] = [] // Refetch the link attachments if needed - if !Settings.disableLinkPreviews { - ZMUserSession.shared()?.enqueue { - message.refetchLinkAttachmentsIfNeeded() + if !Settings.disableLinkPreviews, let id = (message as? ZMMessage)?.objectID { + userSession.enqueue { + let message = ZMMessage.existingObject( + with: id, + inUserSession: userSession.contextProvider + ) + message?.refetchLinkAttachmentsIfNeeded() } } // Text parsing let attachments = message.linkAttachments ?? [] - var messageText = NSAttributedString.format(message: textMessageData, isObfuscated: message.isObfuscated) + var messageText = NSAttributedString.format( + message: textMessageData, + isObfuscated: message.isObfuscated, + accentColor: (selfUser.zmAccentColor ?? .default).accentColor + ) // Search queries if !searchQueries.isEmpty { @@ -213,7 +227,10 @@ extension ConversationTextMessageCellDescription { // Quote if let quotedMessage = textMessageData.quoteMessage { - let quoteCell = ConversationReplyCellDescription(quotedMessage: quotedMessage) + let quoteCell = ConversationReplyCellDescription( + quotedMessage: quotedMessage, + accentColor: (selfUser.zmAccentColor ?? .default).accentColor + ) cells.append(AnyConversationMessageCellDescription(quoteCell)) } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageActionController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageActionController.swift index 854bb86046d..c65f6d45f58 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageActionController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageActionController.swift @@ -28,7 +28,7 @@ final class ConversationMessageActionController { case collection } - let message: ZMConversationMessage + var message: ZMConversationMessage let context: Context /// whether message collapsed or normal | expanded diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift index cf8373bdf9d..e0c040866c3 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift @@ -81,9 +81,15 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { /// The message that is being presented. var message: ConversationMessage { - didSet { updateDelegates() } + didSet { + updateDelegates() + changeObservers.removeAll() + startObservingChanges(for: message) + } } + var selfUser: any UserType + /// The delegate for cells injected by the list adapter. weak var cellDelegate: ConversationMessageCellDelegate? { didSet { updateDelegates() } @@ -117,6 +123,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { init( message: ConversationMessage, context: ConversationMessageContext, + selfUser: any UserType, selected: Bool = false, userSession: UserSession, useInvertedIndices: Bool, @@ -125,6 +132,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { ) { self.message = message self.context = context + self.selfUser = selfUser self.selected = selected self.userSession = userSession self.useInvertedIndices = useInvertedIndices @@ -145,7 +153,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { } private var collapseOwnMessagesEnabled: Bool { - guard let selfUserId = userSession.selfUser.remoteIdentifier else { return false } + guard let selfUserId = selfUser.remoteIdentifier else { return false } return PrivateUserDefaults(userID: selfUserId, storage: userDefaults) .bool(forKey: .collapseOwnMessages) } @@ -167,9 +175,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { return false } - let margins = HorizontalMargins.conversationHorizontalMargins() - - return willTextExceedOneLine(text: textMessage, availableWidth: contentWidth - margins.right - margins.left) + return willTextExceedOneLine(text: textMessage, availableWidth: contentWidth) } else { return message.isSentBySelfUser && message.isCollapsingSupported } @@ -249,6 +255,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { private func addCollapsedCell() -> [AnyConversationMessageCellDescription] { let cellDescriptions = ConversationCollapsedMessageCellDescription( message: message, + accentColor: (selfUser.zmAccentColor ?? .default).accentColor, collapseExpandAction: { [weak self] in self?.handleCollapseExpand() } @@ -260,7 +267,13 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { if needToAddCollapsedCell() { return addCollapsedCell() } - return ConversationTextMessageCellDescription.cells(for: message, searchQueries: context.searchQueries) + return ConversationTextMessageCellDescription + .cells( + for: message, + searchQueries: context.searchQueries, + selfUser: selfUser, + userSession: userSession + ) } private func addLocationMessageCells() -> [AnyConversationMessageCellDescription] { @@ -303,7 +316,10 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { ConversationSystemMessageCellDescription.cells( for: message, isCollapsed: isCollapsed, - buttonAction: buttonAction + buttonAction: buttonAction, + selfUser: selfUser, + accentColor: (selfUser.zmAccentColor ?? .default).accentColor.uiColor, + userSession: userSession ) } @@ -324,7 +340,9 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { cells += ConversationTextMessageCellDescription.cells( textMessageData: data, message: message, - searchQueries: context.searchQueries + searchQueries: context.searchQueries, + selfUser: selfUser, + userSession: userSession ) case let .button(data): @@ -372,13 +390,17 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { let description = BurstTimestampSenderMessageCellDescription( message: message, context: context, - accentColor: userSession.selfUser.accentColor + accentColor: selfUser.accentColor ) cellDescriptions.append(AnyConversationMessageCellDescription(description)) } if isSenderVisible, let sender = message.senderUser { - let description = ConversationSenderMessageCellDescription(sender: sender, message: message) + let description = ConversationSenderMessageCellDescription( + sender: sender, + selfUser: selfUser, + message: message + ) cellDescriptions.append(AnyConversationMessageCellDescription(description)) } @@ -412,6 +434,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { } private func updateDelegates() { + actionController?.message = message cellDescriptions.forEach { cellDescription in cellDescription.message = message cellDescription.actionController = actionController @@ -538,7 +561,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { // MARK: - Changes private func startObservingChanges(for message: ZMConversationMessage) { - guard let userSession = ZMUserSession.shared() else { return } + guard let userSession = userSession as? ZMUserSession else { return } let observer = MessageChangeInfo.add(observer: self, for: message, userSession: userSession) changeObservers.append(observer) diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/NSAttributedString+Mentions.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/NSAttributedString+Mentions.swift index 592c8664ffb..fd59522eb01 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/NSAttributedString+Mentions.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/NSAttributedString+Mentions.swift @@ -18,6 +18,8 @@ import Foundation import WireDataModel +import WireFoundation +import WireReusableUIComponents private let log = ZMSLog(tag: "Mentions") @@ -76,17 +78,14 @@ extension NSMutableAttributedString { for user: UserType, name: String, link: URL, + accentColor: AccentColor, suggestedAttributes: [NSAttributedString.Key: Any] = [:] ) -> NSAttributedString { - let color: UIColor - let backgroundColor: UIColor - - if user.isSelfUser { - color = .accent() - backgroundColor = .lowAccentColorForUsernameMention() + let color: UIColor = accentColor.uiColor + let backgroundColor: UIColor = if user.isSelfUser { + .lowAccentColorForUsernameMention(accentColor: accentColor) } else { - color = .accent() - backgroundColor = .clear + .clear } let suggestedFont = suggestedAttributes[.font] as? UIFont ?? UIFont.normalMediumFont @@ -125,7 +124,8 @@ extension NSMutableAttributedString { func highlight( mentions: [TextMarker], - paragraphStyle: NSParagraphStyle? = NSAttributedString.paragraphStyle + paragraphStyle: NSParagraphStyle? = NSAttributedString.paragraphStyle, + accentColor: AccentColor ) { mentions.forEach { textObject in @@ -142,6 +142,7 @@ extension NSMutableAttributedString { for: textObject.value.user, name: textObject.replacementText, link: textObject.value.link, + accentColor: accentColor, suggestedAttributes: attributes ) diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/NSAttributedString+MessageFormatting.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/NSAttributedString+MessageFormatting.swift index 4345f3f5197..012142bb970 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/NSAttributedString+MessageFormatting.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/NSAttributedString+MessageFormatting.swift @@ -20,6 +20,7 @@ import Down import UIKit import WireDataModel import WireDesign +import WireFoundation import WireLinkPreview import WireUtilities @@ -93,8 +94,11 @@ extension NSAttributedString { return style } - @objc - static func formatForPreview(message: TextMessageData, inputMode: Bool) -> NSAttributedString { + static func formatForPreview( + message: TextMessageData, + inputMode: Bool, + accentColor: AccentColor + ) -> NSAttributedString { var plainText = message.messageText ?? "" // Substitute mentions with text markers @@ -104,7 +108,12 @@ extension NSAttributedString { let markdownText = NSMutableAttributedString.markdown(from: plainText, style: previewStyle) // Highlight mentions using previously inserted text markers - markdownText.highlight(mentions: mentionTextObjects, paragraphStyle: nil) + markdownText + .highlight( + mentions: mentionTextObjects, + paragraphStyle: nil, + accentColor: accentColor + ) // Remove trailing link if we show a link preview let links = markdownText.links() @@ -128,15 +137,15 @@ extension NSAttributedString { return markdownText } - @objc - static func format(message: TextMessageData, isObfuscated: Bool) -> NSAttributedString { + static func format(message: TextMessageData, isObfuscated: Bool, accentColor: AccentColor) -> NSAttributedString { var plainText = message.messageText ?? "" guard !isObfuscated else { + let color: UIColor = accentColor.uiColor let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont(name: "RedactedScript-Regular", size: 18)!, - .foregroundColor: UIColor.accent(), + .foregroundColor: color, .paragraphStyle: paragraphStyle ] return NSAttributedString(string: plainText, attributes: attributes) @@ -149,7 +158,7 @@ extension NSAttributedString { let markdownText = NSMutableAttributedString.markdown(from: plainText, style: style) // Highlight mentions using previously inserted text markers - markdownText.highlight(mentions: mentionTextObjects) + markdownText.highlight(mentions: mentionTextObjects, accentColor: accentColor) // Remove trailing link if we show a link preview if let linkPreview = message.linkPreview { diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+Forward.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+Forward.swift index 2099505a97e..9b87aa7646d 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+Forward.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+Forward.swift @@ -158,7 +158,7 @@ extension ZMConversationMessage { } } -// MARK: - popover apperance update +// MARK: - popover appearance update extension ConversationContentViewController { @@ -183,8 +183,6 @@ extension ConversationContentViewController { // is hidden or shown. This is a quick fix for now. To improve later coordinator.animate(alongsideTransition: nil) { _ in self.dataSource.resetSectionControllers() - self.dataSource.reloadSections(newSections: self.dataSource.calculateSections()) - self.tableView.reloadData() } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+PeekPop.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+PeekPop.swift index 04cbb1375f6..fe4f943ae99 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+PeekPop.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+PeekPop.swift @@ -37,7 +37,7 @@ extension ConversationContentViewController: UIViewControllerPreviewingDelegate return .none } - let message = dataSource.messages[cellIndexPath.section] + let message = dataSource.allMessages[cellIndexPath.section] guard !message.isObfuscated else { return nil } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift index d4cad2b7811..ce1d9e8e6a3 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift @@ -89,7 +89,8 @@ final class ConversationContentViewController: UIViewController { tableView: tableView, actionResponder: self, cellDelegate: self, - userSession: userSession + userSession: userSession, + getUserByIDUseCase: GetUserByIdUseCase() ) /// Fired regularly in order to always correct time values (like the number of seconds a self-deleting message has @@ -253,7 +254,6 @@ final class ConversationContentViewController: UIViewController { @objc private func applicationDidBecomeActive(_ notification: Notification) { dataSource.resetSectionControllers() - tableView.reloadData() } private func handleScrollToBottomTapped() { @@ -315,7 +315,8 @@ final class ConversationContentViewController: UIViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - dataSource.contentWidth = tableView.bounds.width + let margins = HorizontalMargins.conversationHorizontalMargins() + dataSource.contentWidth = tableView.bounds.width - margins.right - margins.left scrollToFirstUnreadMessageIfNeeded() } @@ -351,7 +352,8 @@ final class ConversationContentViewController: UIViewController { @discardableResult func willSelectRow(at indexPath: IndexPath, tableView: UITableView) -> IndexPath? { - guard dataSource.messages.indices.contains(indexPath.section) == true else { return nil } + let messages = dataSource.allMessages + guard messages.indices.contains(indexPath.section) == true else { return nil } // If the menu is visible, hide it and do nothing if UIMenuController.shared.isMenuVisible { @@ -359,7 +361,7 @@ final class ConversationContentViewController: UIViewController { return nil } - let message = dataSource.messages[indexPath.section] + let message = messages[indexPath.section] if message == dataSource.selectedMessage { @@ -422,12 +424,12 @@ final class ConversationContentViewController: UIViewController { let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows - if let firstIndexPath = indexPathsForVisibleRows?.first { - let lastVisibleMessage = dataSource.messages[firstIndexPath.section] + if let firstIndexPath = indexPathsForVisibleRows?.first, + let lastVisibleMessage = dataSource.allMessages[ifExists: firstIndexPath.section] { conversation.markMessagesAsRead(until: lastVisibleMessage) } - // Update media bar visiblity + // Update media bar visibility updateMediaBar() } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift index d529b8d9ea6..d60f7da30b6 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift @@ -18,16 +18,20 @@ import DifferenceKit import WireDataModel +import WireFoundation +import WireLogging import WireSyncEngine extension Int: Differentiable {} extension String: Differentiable {} +extension UUID: Differentiable {} + extension AnyConversationMessageCellDescription: Differentiable { typealias DifferenceIdentifier = String var differenceIdentifier: String { - message!.objectIdentifier + String(describing: baseType) + String(describing: (message as! ZMMessage).objectID) + String(describing: baseType) } override var debugDescription: String { @@ -40,15 +44,6 @@ extension AnyConversationMessageCellDescription: Differentiable { } -extension ZMConversationMessage { - - var isSentFromThisDevice: Bool { - guard let sender = senderUser else { return false } - return sender.isSelfUser && deliveryState == .pending - } - -} - final class ConversationTableViewDataSource: NSObject { static let defaultBatchSize = 30 // Magic number: amount of messages per screen (upper bound). @@ -57,19 +52,15 @@ final class ConversationTableViewDataSource: NSObject { private var lastFetchedObjectCount: Int = 0 var registeredCells: [AnyClass] = [] - var sectionControllers: [String: ConversationMessageSectionController] = [:] + + var sectionControllers = ThreadSafeDictionary() private(set) var hasOlderMessagesToLoad = false private(set) var hasNewerMessagesToLoad = false let userSession: UserSession - func resetSectionControllers() { - sectionControllers = [:] - calculateSections() - } - - var actionControllers: [String: ConversationMessageActionController] = [:] + var actionControllers = ThreadSafeDictionary() let conversation: ZMConversation let tableView: UpsideDownTableView @@ -80,25 +71,26 @@ final class ConversationTableViewDataSource: NSObject { weak var conversationCellDelegate: ConversationMessageCellDelegate? weak var messageActionResponder: MessageActionResponder? + private let getUserByIDUseCase: GetUserByIDUseCaseProtocol + + let debouncer = LeadingTrailingDebouncer(cooldownTime: 0.3) var contentWidth: CGFloat = UIScreen.main.bounds.width { didSet { guard UIDevice.current.userInterfaceIdiom == .pad else { return } resetSectionControllers() - reloadSections(newSections: postProcessedSections(calculateSections())) - tableView.reloadData() } } var searchQueries: [String] = [] { didSet { - let currentSections = calculateSections() - self.currentSections = postProcessedSections(currentSections) - tableView.reloadData() + calculateSections { [weak self] sections in + self?.reloadSections(newSections: sections) + } } } - var messages: [ZMMessage] { + var allMessages: [ZMMessage] { // NOTE: We limit the number of messages to the `lastFetchedObjectCount` since the // NSFetchResultsController will add objects to `fetchObjects` if they are modified after // the initial fetch, which results in unwanted table view updates. This is normally what @@ -117,34 +109,121 @@ final class ConversationTableViewDataSource: NSObject { /// /// - Parameter forceRecalculate: true if force recreate cell with context check /// - Returns: arraySection of cell descriptions - @discardableResult func calculateSections( - forceRecalculate: Bool = false - ) -> [Section] { - messages.enumerated().map { offset, element in - let sectionIdentifier = element.objectIdentifier - let context = context( - for: element, - at: offset, - firstUnreadMessage: firstUnreadMessage, - searchQueries: searchQueries - ) - let sectionController = sectionController(for: element, at: offset) - - // Re-create cell description if the context has changed (message has been moved around or received new - // neighbors). - if sectionController.context != context || forceRecalculate { - sectionController.recreateCellDescriptions(in: context) + forceRecalculate: Bool = false, + completion: @escaping ([Section]) -> Void + ) { + let mainThreadContext = userSession.contextProvider.viewContext + let messagesOnMainThread = allMessages + let messageIds = messagesOnMainThread.map(\.objectID) + let selfUserOnMainThread = userSession.selfUser + let selfUserObjectID = selfUserOnMainThread.objectId + let firstUnreadMessageNonce = firstUnreadMessage?.nonce + + // Dispatching to background thread to offload sections calculation + + let backgroundContext = userSession.contextProvider.newBackgroundContext() + backgroundContext.perform { [weak self] in + guard let self else { return } + + var messages: [ZMMessage] = messageIds.compactMap { objectID in + try? backgroundContext.existingObject(with: objectID) as? ZMMessage + } + + // sort if needed + messages = messages.sorted { + ($0.serverTimestamp ?? .distantPast) > ($1.serverTimestamp ?? .distantPast) + } + + guard let selfUserOnBackgroundThread = getUserByIDUseCase.getUserByID( + id: selfUserObjectID, + context: backgroundContext + ) else { + DispatchQueue.main.async { + completion(self.currentSections) + } + return + } + + // Go through messages and calculate sections + let result = messages.enumerated().map { offset, element in + let context = self.context( + for: element, + at: offset, + firstUnreadMessageNonce: firstUnreadMessageNonce, + searchQueries: self.searchQueries, + messages: messages + ) + + let sectionController = if let cachedSectionController = self.sectionControllers + .get(for: element.nonce!) { + cachedSectionController + } else { + self.makeSectionController( + message: element, + index: offset, + messages: messages, + selfUser: selfUserOnBackgroundThread, + firstUnreadMessageNonce: firstUnreadMessageNonce + ) + } + + // Re-create cell description if the context has changed (message has been moved around + // or received new neighbours). + return (element.nonce!, sectionController, context) } - return ArraySection(model: sectionIdentifier, elements: sectionController.tableViewCellDescriptions) + // Return back to main thread + DispatchQueue.main.async { + var sections = [Section]() + + let allMessages = messagesOnMainThread + for (messageObjectId, sectionController, context) in result { + + // saving calculations result in local cache + self.sectionControllers.set(value: sectionController, for: messageObjectId) + self.actionControllers.set(value: sectionController.actionController, for: messageObjectId) + + // Re-set messages from Main thread to section controller to not have crash with later interactions + // with data + if let managedID = (sectionController.message as? ZMMessage)?.objectID, + let mainThreadMessage = try? mainThreadContext.existingObject(with: managedID) as? ZMMessage { + sectionController.message = mainThreadMessage + } else { + WireLogger.conversation + .debug( + "No message found to reset from background to main thread, nonce: \(String(describing: sectionController.message.nonce))" + ) + } + + sectionController.selfUser = selfUserOnMainThread + + if sectionController.context != context || forceRecalculate { + sectionController.recreateCellDescriptions(in: context) + } + + sections.append(ArraySection( + model: messageObjectId, + elements: sectionController.tableViewCellDescriptions + )) + } + + completion( + self.postProcessedSections( + sections, + selfUser: selfUserOnMainThread, + allMessages: messagesOnMainThread + ) + ) + } } + } func calculateSections( updating sectionController: ConversationMessageSectionController ) -> [Section] { - let sectionIdentifier = sectionController.message.objectIdentifier + let sectionIdentifier = sectionController.message.nonce! guard let section = currentSections.firstIndex(where: { $0.model == sectionIdentifier }) else { return currentSections } @@ -160,12 +239,16 @@ final class ConversationTableViewDataSource: NSObject { } } + let messages = allMessages + let context = context( for: sectionController.message, at: section, - firstUnreadMessage: firstUnreadMessage, - searchQueries: searchQueries + firstUnreadMessageNonce: firstUnreadMessage?.nonce, + searchQueries: searchQueries, + messages: messages ) + sectionController.recreateCellDescriptions(in: context) var updatedSections = currentSections @@ -173,7 +256,11 @@ final class ConversationTableViewDataSource: NSObject { model: sectionIdentifier, elements: sectionController.tableViewCellDescriptions ) - updatedSections = postProcessedSections(updatedSections) + updatedSections = postProcessedSections( + updatedSections, + selfUser: userSession.selfUser, + allMessages: messages + ) return updatedSections } @@ -183,60 +270,82 @@ final class ConversationTableViewDataSource: NSObject { tableView: UpsideDownTableView, actionResponder: MessageActionResponder, cellDelegate: ConversationMessageCellDelegate, - userSession: UserSession + userSession: UserSession, + getUserByIDUseCase: GetUserByIDUseCaseProtocol ) { self.messageActionResponder = actionResponder self.conversationCellDelegate = cellDelegate self.conversation = conversation self.tableView = tableView self.userSession = userSession - + self.getUserByIDUseCase = getUserByIDUseCase super.init() tableView.dataSource = self } + func resetSectionControllers() { + sectionControllers.reset() + calculateSections { [weak self] sections in + guard let self else { return } + currentSections = sections + tableView.reloadData() + } + } + func section(for message: ZMConversationMessage) -> Int? { - currentSections.firstIndex(where: { $0.model == message.objectIdentifier }) + currentSections.firstIndex(where: { $0.model == message.nonce }) } - func actionController( - for message: ZMConversationMessage, - sectionController: ConversationMessageSectionController + func getOrCreateActionController( + for message: ZMMessage, + sectionController: ConversationMessageSectionController, + selfUser: any UserType ) -> ConversationMessageActionController { - if let cachedEntry = actionControllers[message.objectIdentifier] { + if let cachedEntry = actionControllers.get(for: message.nonce!) { return cachedEntry } - let actionController = ConversationMessageActionController( + return makeActionController( + for: message, + sectionController: sectionController, + selfUser: selfUser + ) + } + + func makeActionController( + for message: ZMConversationMessage, + sectionController: ConversationMessageSectionController, + selfUser: any UserType + ) -> ConversationMessageActionController { + ConversationMessageActionController( responder: messageActionResponder, message: message, context: .content, view: tableView, isCollapsed: sectionController.isCollapsed, - selfUserId: userSession.selfUser.remoteIdentifier + selfUserId: selfUser.remoteIdentifier ) - - actionControllers[message.objectIdentifier] = actionController - - return actionController } - func sectionController(for message: ConversationMessage, at index: Int) -> ConversationMessageSectionController { - if let cachedEntry = sectionControllers[message.objectIdentifier] { - cachedEntry.contentWidth = contentWidth - return cachedEntry - } - + private func makeSectionController( + message: ZMMessage, + index: Int, + messages: [ZMMessage], + selfUser: any UserType, + firstUnreadMessageNonce: UUID? + ) -> ConversationMessageSectionController { let context = context( for: message, at: index, - firstUnreadMessage: firstUnreadMessage, - searchQueries: searchQueries + firstUnreadMessageNonce: firstUnreadMessageNonce, + searchQueries: searchQueries, + messages: messages ) let sectionController = ConversationMessageSectionController( message: message, context: context, + selfUser: selfUser, selected: message.isEqual(selectedMessage), userSession: userSession, useInvertedIndices: true, @@ -244,16 +353,51 @@ final class ConversationTableViewDataSource: NSObject { ) sectionController.cellDelegate = conversationCellDelegate sectionController.sectionDelegate = self - sectionController.actionController = actionController(for: message, sectionController: sectionController) + sectionController.actionController = getOrCreateActionController( + for: message, + sectionController: sectionController, + selfUser: selfUser + ) - sectionControllers[message.objectIdentifier] = sectionController + return sectionController + } + + func getOrCreateSectionController( + for message: ZMMessage, + at index: Int, + selfUser: any UserType, + messages: [ZMMessage] + ) -> ConversationMessageSectionController { + if let cachedEntry = sectionControllers.get(for: message.nonce!) { + cachedEntry.contentWidth = contentWidth + return cachedEntry + } + + let sectionController = makeSectionController( + message: message, + index: index, + messages: messages, + selfUser: selfUser, + firstUnreadMessageNonce: firstUnreadMessage?.nonce + ) + + sectionControllers.set(value: sectionController, for: message.nonce!) return sectionController } - func sectionController(at sectionIndex: Int) -> ConversationMessageSectionController { + func sectionController( + at sectionIndex: Int, + selfUser: any UserType, + messages: [ZMMessage] + ) -> ConversationMessageSectionController { let message = messages[sectionIndex] - return sectionController(for: message, at: sectionIndex) + return getOrCreateSectionController( + for: message, + at: sectionIndex, + selfUser: selfUser, + messages: messages + ) } func loadMessages( @@ -284,16 +428,18 @@ final class ConversationTableViewDataSource: NSObject { let offset = max(0, index - ConversationTableViewDataSource.defaultBatchSize) let limit = ConversationTableViewDataSource.defaultBatchSize * 2 - loadMessages(offset: offset, limit: limit, forceRecalculate: forceRecalculate) - - let indexPath = topIndexPath(for: message) - completion?(indexPath) + loadMessages(offset: offset, limit: limit, forceRecalculate: forceRecalculate) { [weak self] in + guard let self else { return } + let indexPath = topIndexPath(for: message) + completion?(indexPath) + } } func loadMessages( offset: Int = 0, limit: Int = ConversationTableViewDataSource.defaultBatchSize, - forceRecalculate: Bool = false + forceRecalculate: Bool = false, + completion: (() -> Void)? = nil ) { let fetchRequest = fetchRequest() fetchRequest @@ -314,30 +460,45 @@ final class ConversationTableViewDataSource: NSObject { try! fetchController?.performFetch() lastFetchedObjectCount = fetchController?.fetchedObjects?.count ?? 0 - hasOlderMessagesToLoad = messages.count == fetchRequest.fetchLimit + hasOlderMessagesToLoad = allMessages.count == fetchRequest.fetchLimit hasNewerMessagesToLoad = offset > 0 firstUnreadMessage = conversation.firstUnreadMessage - let currentSections = calculateSections(forceRecalculate: forceRecalculate) - self.currentSections = postProcessedSections(currentSections) - tableView.reloadData() + + calculateSections(forceRecalculate: forceRecalculate) { [weak self] sections in + self?.currentSections = sections + self?.tableView.reloadData() + completion?() + } } + var loadingMessages = false + private func loadOlderMessages() { + guard !loadingMessages else { + return + } guard let currentOffset = fetchController?.fetchRequest.fetchOffset, let currentLimit = fetchController?.fetchRequest.fetchLimit else { return } let newLimit = currentLimit + ConversationTableViewDataSource.defaultBatchSize + loadingMessages = true - loadMessages(offset: currentOffset, limit: newLimit) + loadMessages(offset: currentOffset, limit: newLimit) { [weak self] in + self?.loadingMessages = false + } } func loadNewerMessages() { + guard !loadingMessages else { return } + guard let currentOffset = fetchController?.fetchRequest.fetchOffset, let currentLimit = fetchController?.fetchRequest.fetchLimit else { return } let newOffset = max(0, currentOffset - ConversationTableViewDataSource.defaultBatchSize) - loadMessages(offset: newOffset, limit: currentLimit) + loadMessages(offset: newOffset, limit: currentLimit) { [weak self] in + self?.loadingMessages = false + } } func indexOfMessage(_ message: ZMConversationMessage) -> Int { @@ -387,7 +548,7 @@ final class ConversationTableViewDataSource: NSObject { // To avoid loosing scroll position: // 1. Remember the newest message now - let newestMessageBeforeReload = messages.first + let newestMessageBeforeReload = allMessages.first // 2. Load more messages loadNewerMessages() @@ -441,7 +602,11 @@ extension ConversationTableViewDataSource: NSFetchedResultsControllerDelegate { } func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - reloadSections(newSections: postProcessedSections(calculateSections())) + debouncer.call(id: nil) { [weak self] in + self?.calculateSections { sections in + self?.reloadSections(newSections: sections) + } + } } func reloadSections(newSections: [Section]) { @@ -458,15 +623,23 @@ extension ConversationTableViewDataSource: UITableViewDataSource { } func select(indexPath: IndexPath) { - let sectionController = sectionController(at: indexPath.section) + let sectionController = sectionController( + at: indexPath.section, + selfUser: userSession.selfUser, + messages: allMessages + ) sectionController.didSelect() - reloadSections(newSections: postProcessedSections(calculateSections(updating: sectionController))) + reloadSections(newSections: calculateSections(updating: sectionController)) } func deselect(indexPath: IndexPath) { - let sectionController = sectionController(at: indexPath.section) + let sectionController = sectionController( + at: indexPath.section, + selfUser: userSession.selfUser, + messages: allMessages + ) sectionController.didDeselect() - reloadSections(newSections: postProcessedSections(calculateSections(updating: sectionController))) + reloadSections(newSections: calculateSections(updating: sectionController)) } func highlight(message: ZMConversationMessage) { @@ -474,12 +647,16 @@ extension ConversationTableViewDataSource: UITableViewDataSource { return } - let sectionController = sectionController(at: section) + let sectionController = sectionController( + at: section, + selfUser: userSession.selfUser, + messages: allMessages + ) sectionController.highlight(in: tableView, sectionIndex: section) } func collapse(message: ZMConversationMessage) { - guard let section = sectionControllers[message.objectIdentifier] else { + guard let section = sectionControllers.get(for: message.nonce!) else { return } section.collapse() @@ -538,25 +715,32 @@ extension ConversationTableViewDataSource: ConversationMessageSectionControllerD _ controller: ConversationMessageSectionController, didRequestRefreshForMessage message: ZMConversationMessage ) { - reloadSections(newSections: postProcessedSections(calculateSections(updating: controller))) + debouncer.call(id: message.nonce!) { [weak self] in + guard let self else { return } + reloadSections(newSections: calculateSections(updating: controller)) + } } } extension ConversationTableViewDataSource { - func messageBeforeMessage(at index: Int) -> ZMConversationMessage? { + func messageBeforeMessage(at index: Int, messages: [ZMConversationMessage]) -> ZMConversationMessage? { let previousIndex = index + 1 guard messages.indices.contains(previousIndex) else { return nil } return messages[previousIndex] } - func isPreviousSenderSame(forMessage message: ZMConversationMessage?, at index: Int) -> Bool { + func isPreviousSenderSame( + forMessage message: ZMConversationMessage?, + at index: Int, + messages: [ZMMessage] + ) -> Bool { guard let message, Message.isNormal(message), !Message.isKnock(message) else { return false } - guard let previousMessage = messageBeforeMessage(at: index), + guard let previousMessage = messageBeforeMessage(at: index, messages: messages), previousMessage.senderUser === message.senderUser, Message.isNormal(previousMessage) else { return false } @@ -566,13 +750,14 @@ extension ConversationTableViewDataSource { func context( for message: ZMConversationMessage, at index: Int, - firstUnreadMessage: ZMConversationMessage?, - searchQueries: [String] + firstUnreadMessageNonce: UUID?, + searchQueries: [String], + messages: [ZMMessage] ) -> ConversationMessageContext { let isTimestampInSameMinuteAsPreviousMessage: Bool - let previousMessage = messageBeforeMessage(at: index) + let previousMessage = messageBeforeMessage(at: index, messages: messages) if let currentMessage = message.serverTimestamp, let prevMessage = previousMessage?.serverTimestamp { isTimestampInSameMinuteAsPreviousMessage = currentMessage.isInSameMinute(asDate: prevMessage) @@ -582,23 +767,27 @@ extension ConversationTableViewDataSource { let isLastMessage = (index == 0) && !hasNewerMessagesToLoad return ConversationMessageContext( - isSameSenderAsPrevious: isPreviousSenderSame(forMessage: message, at: index), + isSameSenderAsPrevious: isPreviousSenderSame(forMessage: message, at: index, messages: messages), isTimestampInSameMinuteAsPreviousMessage: isTimestampInSameMinuteAsPreviousMessage, - isFirstMessageOfTheDay: isFirstMessageOfTheDay(for: message, at: index), - isFirstUnreadMessage: message.isEqual(firstUnreadMessage), + isFirstMessageOfTheDay: isFirstMessageOfTheDay(for: message, at: index, messages: messages), + isFirstUnreadMessage: message.nonce == firstUnreadMessageNonce, isLastMessage: isLastMessage, searchQueries: searchQueries, previousMessageIsKnock: previousMessage?.isKnock == true ) } - private func isFirstMessageOfTheDay(for message: ZMConversationMessage, at index: Int) -> Bool { - guard let previous = messageBeforeMessage(at: index)?.serverTimestamp, + private func isFirstMessageOfTheDay( + for message: ZMConversationMessage, + at index: Int, + messages: [ZMConversationMessage] + ) -> Bool { + guard let previous = messageBeforeMessage(at: index, messages: messages)?.serverTimestamp, let current = message.serverTimestamp else { return false } return !Calendar.current.isDate(current, inSameDayAs: previous) } - typealias Section = ArraySection + typealias Section = ArraySection /// Iterates over the sections (messages) and compares two subsequent messages. Based on that some minor /// modifications are applied. @@ -606,10 +795,16 @@ extension ConversationTableViewDataSource { /// - If a message doesn't show the sender (because it was sent just a moment after the previous one), the space /// between the messages is reduced. /// - If a message's status does not provide relevant info over a subsequent message's status, it is hidden. - private func postProcessedSections(_ sections: [Section]) -> [Section] { + private func postProcessedSections( + _ sections: [Section], + selfUser: any UserType, + allMessages: [ZMMessage] + ) -> [Section] { var sections = sections + let messages = allMessages + // find subsequent messages and collapse space if needed for currentSectionIndex in sections.indices.reversed() { // The lowest index refers to the latest message. @@ -631,8 +826,13 @@ extension ConversationTableViewDataSource { } // filter redundant status cells - if - isMessageStatus(of: previousSectionIndex, redundantTo: currentSectionIndex, in: sections), + if isMessageStatus( + of: previousSectionIndex, + redundantTo: currentSectionIndex, + in: sections, + selfUser: selfUser, + messages: messages + ), messages.indices.contains(previousSectionIndex) { // collapse the status view's height @@ -704,7 +904,9 @@ extension ConversationTableViewDataSource { private func isMessageStatus( of previousIndex: Int, redundantTo currentIndex: Int, - in sections: [Section] + in sections: [Section], + selfUser: any UserType, + messages: [ZMMessage] ) -> Bool { guard messages.indices.contains(previousIndex), messages.indices.contains(currentIndex) else { return false @@ -729,7 +931,7 @@ extension ConversationTableViewDataSource { } // if the current message is collapsed, show status for the previous - if sectionController(at: currentIndex).isCollapsed { + if sectionController(at: currentIndex, selfUser: selfUser, messages: messages).isCollapsed { return false } @@ -781,3 +983,12 @@ extension ZMConversationMessage { textMessageData?.messageText } } + +extension ZMConversationMessage { + + var isSentFromThisDevice: Bool { + guard let sender = senderUser else { return false } + return sender.isSelfUser && deliveryState == .pending + } + +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/GetUserByIdUseCase.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/GetUserByIdUseCase.swift new file mode 100644 index 00000000000..e69427c1cd8 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/GetUserByIdUseCase.swift @@ -0,0 +1,34 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import CoreData +import Foundation +import WireDataModel + +protocol GetUserByIDUseCaseProtocol { + func getUserByID(id: Any, context: NSManagedObjectContext) -> (any UserType)? +} + +class GetUserByIdUseCase: GetUserByIDUseCaseProtocol { + func getUserByID(id: Any, context: NSManagedObjectContext) -> (any UserType)? { + guard let managedObjectId = id as? NSManagedObjectID else { + return nil + } + return try? context.existingObject(with: managedObjectId) as? UserType + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Helpers/UIColor+Accent.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Helpers/UIColor+Accent.swift index 71d302fc379..7d456e4fc1f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Helpers/UIColor+Accent.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Helpers/UIColor+Accent.swift @@ -88,8 +88,8 @@ extension UIColor { } } - class func lowAccentColorForUsernameMention() -> UIColor { - switch (indexedAccentColor() ?? .default).accentColor { + class func lowAccentColorForUsernameMention(accentColor: AccentColor) -> UIColor { + switch accentColor { case .blue: SemanticColors.View.backgroundBlueUsernameMention case .red: