diff --git a/WireDomain/Sources/WireDomain/Repositories/Connections/ConnectionsLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/Connections/ConnectionsLocalStore.swift index c1f76e50ee1..2c067303c2e 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Connections/ConnectionsLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Connections/ConnectionsLocalStore.swift @@ -50,10 +50,16 @@ final class ConnectionsLocalStore: ConnectionsLocalStoreProtocol { // `ConnectionValidator` cleans up stale connections between users, so we normally (re)set this link here. // But when the two users already have an established MLS conversation, we keep it: overwriting it with the - // Proteus connection conversation would break the link and hide the conversation from the list — which is - // what happens when blocking the user. + // Proteus connection conversation would break the link and hide the conversation from the list. + // + // `migratedToMLS` is only set on the proteus→MLS migration path, so it misses MLS one-on-ones that were + // established directly. Relying on it alone lets the proteus connection conversation overwrite the MLS + // link, which surfaces the proteus (read-only) conversation instead of the MLS one — including on the + // side of a user who was blocked, since that side is never notified and should keep messaging. [WPB-24403] let existing = connection.to.oneOnOneConversation - if existing?.messageProtocol != .mls || existing?.migratedToMLS != true { + let isEstablishedMLS = existing?.messageProtocol == .mls + && (existing?.mlsStatus == .ready || existing?.migratedToMLS == true) + if !isEstablishedMLS { connection.to.oneOnOneConversation = conversation } connection.status = connectionInfo.status diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore+Group.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore+Group.swift index 0d78f6151ef..c42d18d073d 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore+Group.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore+Group.swift @@ -102,9 +102,18 @@ extension ConversationLocalStore { } guard let otherUser = localConversation.localParticipantsExcludingSelf.first else { - localConversation.isForcedReadOnly = true - if localConversation.messageProtocol.isOne(of: .mls, .mixed) { - localConversation.mlsStatus = .invalid + // The other participant is absent from the synced member list. This is exactly what the + // backend returns to the party that was *blocked*: the blocker is omitted from the 1:1 + // member list. That side is never notified of the block and must keep its conversation + // usable, so we must not tear down an established 1:1 — forcing it read-only and marking + // the MLS group invalid (which wipes it and drops the user back to the read-only Proteus + // conversation). Only do so when there is genuinely no user behind the conversation + // (e.g. a malformed/empty 1:1 that was never linked). [WPB-24403] + if localConversation.oneOnOneUser == nil { + localConversation.isForcedReadOnly = true + if localConversation.messageProtocol.isOne(of: .mls, .mixed) { + localConversation.mlsStatus = .invalid + } } return } diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift index e505def9e5e..7e27ee6b1db 100644 --- a/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift +++ b/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift @@ -169,6 +169,46 @@ final class ConnectionsLocalStoreTests: XCTestCase { } } + func testStoreConnection_GivenLinkedToEstablishedMLSOneOnOne_ItPreservesTheMLSLink() async throws { + // Given a stored connection whose user is linked to an established MLS one-on-one. + // The conversation is created directly as MLS, so `migratedToMLS` stays `false` — the + // case the previous `migratedToMLS`-only guard failed to protect. + try await sut.storeConnection(Scaffolding.connection) + + let mlsConversationID = UUID() + try await context.perform { [context, modelHelper] in + let storedConnection = try XCTUnwrap(ZMConnection.fetch( + userID: Scaffolding.member2ID.uuid, + domain: Scaffolding.member2ID.domain, + in: context + )) + let mlsConversation = modelHelper!.createMLSConversation( + id: mlsConversationID, + mlsStatus: .ready, + conversationType: .oneOnOne, + in: context + ) + storedConnection.to.oneOnOneConversation = mlsConversation + try context.save() + } + + // When the backend reports the proteus connection conversation again (e.g. on a connection + // update such as a block, which the blocked side is never notified about). + try await sut.storeConnection(Scaffolding.connection) + + // Then the MLS link is preserved instead of being overwritten by the proteus conversation. + try await context.perform { [context] in + let storedConnection = try XCTUnwrap(ZMConnection.fetch( + userID: Scaffolding.member2ID.uuid, + domain: Scaffolding.member2ID.domain, + in: context + )) + let relatedConversation = try XCTUnwrap(storedConnection.to.oneOnOneConversation) + XCTAssertEqual(relatedConversation.messageProtocol, .mls) + XCTAssertEqual(relatedConversation.remoteIdentifier, mlsConversationID) + } + } + private enum Scaffolding { static let member1ID = WireDataModel.QualifiedID( uuid: .mockID1, diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift index aa61ddba2b6..0da2ec6ed33 100644 --- a/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift +++ b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift @@ -544,6 +544,57 @@ final class ConversationLocalStoreTests: XCTestCase { } } + func testLinkOneOnOneUserIfNeeded_GivenLinkedUserButNoParticipants_ItKeepsMLSConversationUsable() async { + // Given an established MLS 1:1 linked to its one-on-one user but with no participants — the + // state the blocked party ends up in once the backend removes the blocker from the members. + let conversation = await context.perform { [self] in + _ = modelHelper.createSelfUser(in: context) + let conversation = modelHelper.createMLSConversation( + mlsStatus: .ready, + conversationType: .oneOnOne, + in: context + ) + let otherUser = modelHelper.createUser(id: Scaffolding.otherUserID, domain: Scaffolding.domain, in: context) + conversation.oneOnOneUser = otherUser + return conversation + } + + // When + await context.perform { [self] in + sut.linkOneOnOneUserIfNeeded(for: conversation) + } + + // Then the MLS 1:1 is preserved and remains usable (not wiped, not read-only). + await context.perform { + XCTAssertEqual(conversation.mlsStatus, .ready) + XCTAssertEqual(conversation.messageProtocol, .mls) + XCTAssertFalse(conversation.isForcedReadOnly) + } + } + + func testLinkOneOnOneUserIfNeeded_GivenNoLinkedUserAndNoParticipants_ItInvalidatesMLSConversation() async { + // Given an MLS 1:1 with no linked one-on-one user (a genuinely empty/malformed 1:1). + let conversation = await context.perform { [self] in + _ = modelHelper.createSelfUser(in: context) + return modelHelper.createMLSConversation( + mlsStatus: .ready, + conversationType: .oneOnOne, + in: context + ) + } + + // When + await context.perform { [self] in + sut.linkOneOnOneUserIfNeeded(for: conversation) + } + + // Then the MLS group is invalidated and the conversation forced read-only. + await context.perform { + XCTAssertEqual(conversation.mlsStatus, .invalid) + XCTAssertTrue(conversation.isForcedReadOnly) + } + } + private enum Scaffolding { static let selfUserId = UUID.mockID1 diff --git a/wire-ios-request-strategy/Sources/Payloads/Processing/ConnectionPayloadProcessor.swift b/wire-ios-request-strategy/Sources/Payloads/Processing/ConnectionPayloadProcessor.swift index 46322123049..ef3df50b64f 100644 --- a/wire-ios-request-strategy/Sources/Payloads/Processing/ConnectionPayloadProcessor.swift +++ b/wire-ios-request-strategy/Sources/Payloads/Processing/ConnectionPayloadProcessor.swift @@ -69,10 +69,16 @@ final class ConnectionPayloadProcessor { // `ConnectionValidator` cleans up stale connections between users, so we normally (re)set this link here. // But when the two users already have an established MLS conversation, we keep it: overwriting it with the - // Proteus connection conversation would break the link and hide the conversation from the list — which is - // what happens when blocking the user. + // Proteus connection conversation would break the link and hide the conversation from the list. + // + // `migratedToMLS` is only set on the proteus→MLS migration path, so it misses MLS one-on-ones that were + // established directly. Relying on it alone lets the proteus connection conversation overwrite the MLS + // link, which surfaces the proteus (read-only) conversation instead of the MLS one — including on the + // side of a user who was blocked, since that side is never notified and should keep messaging. [WPB-24403] let existing = connection.to.oneOnOneConversation - if existing?.messageProtocol != .mls || existing?.migratedToMLS != true { + let isEstablishedMLS = existing?.messageProtocol == .mls + && (existing?.mlsStatus == .ready || existing?.migratedToMLS == true) + if !isEstablishedMLS { connection.to.oneOnOneConversation = conversation } connection.status = payload.status.internalStatus diff --git a/wire-ios-request-strategy/Sources/Payloads/Processing/ConversationEventPayloadProcessor.swift b/wire-ios-request-strategy/Sources/Payloads/Processing/ConversationEventPayloadProcessor.swift index 850196002ff..52c2cf9b2ad 100644 --- a/wire-ios-request-strategy/Sources/Payloads/Processing/ConversationEventPayloadProcessor.swift +++ b/wire-ios-request-strategy/Sources/Payloads/Processing/ConversationEventPayloadProcessor.swift @@ -643,9 +643,18 @@ struct ConversationEventPayloadProcessor { } guard let otherUser = localConversation.localParticipantsExcludingSelf.first else { - localConversation.isForcedReadOnly = true - if localConversation.messageProtocol.isOne(of: .mls, .mixed) { - localConversation.mlsStatus = .invalid + // The other participant is absent from the synced member list. This is exactly what the + // backend returns to the party that was *blocked*: it removes the blocker from the 1:1 + // member list (Proteus and MLS) without notifying this side. We must not tear down an + // established 1:1 — forcing it read-only and marking the MLS group invalid (which wipes + // it and drops the user back to the read-only Proteus conversation) — when we still have + // a linked one-on-one user. Only do so when there is genuinely no user behind the + // conversation (e.g. a malformed/empty 1:1 that was never linked). [WPB-24403] + if localConversation.oneOnOneUser == nil { + localConversation.isForcedReadOnly = true + if localConversation.messageProtocol.isOne(of: .mls, .mixed) { + localConversation.mlsStatus = .invalid + } } return } diff --git a/wire-ios-request-strategy/Tests/Sources/Payloads/Processing/ConnectionPayloadProcessorTests.swift b/wire-ios-request-strategy/Tests/Sources/Payloads/Processing/ConnectionPayloadProcessorTests.swift index 8de87e491b8..dcca54ab85f 100644 --- a/wire-ios-request-strategy/Tests/Sources/Payloads/Processing/ConnectionPayloadProcessorTests.swift +++ b/wire-ios-request-strategy/Tests/Sources/Payloads/Processing/ConnectionPayloadProcessorTests.swift @@ -111,6 +111,61 @@ final class ConnectionPayloadProcessorTests: MessagingTestBase { } } + func testThatAnEstablishedMLSConversationIsNotOverwrittenByTheProteusConnectionConversation() { + syncMOC.performGroupedAndWait { + // given an established MLS one-on-one that was *not* flagged as migrated + // (e.g. created directly as MLS rather than via the proteus→MLS migration) + let mlsConversation = ZMConversation.insertNewObject(in: self.syncMOC) + mlsConversation.domain = self.owningDomain + mlsConversation.remoteIdentifier = UUID.create() + mlsConversation.conversationType = .oneOnOne + mlsConversation.messageProtocol = .mls + mlsConversation.mlsStatus = .ready + mlsConversation.migratedToMLS = false + self.otherUser.oneOnOneConversation = mlsConversation + + // when the backend reports the proteus connection conversation + let payload = self.createConnectionPayload( + to: self.otherUser.qualifiedID!, + conversation: self.oneToOneConversation.qualifiedID! + ) + self.sut.updateOrCreateConnection( + from: payload, + in: self.syncMOC + ) + + // then the MLS link is preserved (the proteus conversation does not shadow it) + XCTAssertEqual(self.otherUser.oneOnOneConversation, mlsConversation) + } + } + + func testThatANonEstablishedMLSConversationIsOverwrittenByTheProteusConnectionConversation() { + syncMOC.performGroupedAndWait { + // given an MLS one-on-one whose group is not yet established and was not migrated + let mlsConversation = ZMConversation.insertNewObject(in: self.syncMOC) + mlsConversation.domain = self.owningDomain + mlsConversation.remoteIdentifier = UUID.create() + mlsConversation.conversationType = .oneOnOne + mlsConversation.messageProtocol = .mls + mlsConversation.mlsStatus = .pendingJoin + mlsConversation.migratedToMLS = false + self.otherUser.oneOnOneConversation = mlsConversation + + // when the backend reports the proteus connection conversation + let payload = self.createConnectionPayload( + to: self.otherUser.qualifiedID!, + conversation: self.oneToOneConversation.qualifiedID! + ) + self.sut.updateOrCreateConnection( + from: payload, + in: self.syncMOC + ) + + // then the link is (re)set to the proteus connection conversation + XCTAssertEqual(self.otherUser.oneOnOneConversation, self.oneToOneConversation) + } + } + func testThatOtherUserIsAddedToConversation() { syncMOC.performGroupedAndWait { // given diff --git a/wire-ios-request-strategy/Tests/Sources/Payloads/Processing/ConversationEventPayloadProcessorTests.swift b/wire-ios-request-strategy/Tests/Sources/Payloads/Processing/ConversationEventPayloadProcessorTests.swift index 74ab1b95952..1871e6bba1c 100644 --- a/wire-ios-request-strategy/Tests/Sources/Payloads/Processing/ConversationEventPayloadProcessorTests.swift +++ b/wire-ios-request-strategy/Tests/Sources/Payloads/Processing/ConversationEventPayloadProcessorTests.swift @@ -709,6 +709,51 @@ final class ConversationEventPayloadProcessorTests: MessagingTestBase { // MARK: 1:1 / Connection Conversations + func testUpdateOrCreateConversation_OneToOne_PreservesEstablishedMLSWhenOtherMemberMissing() async throws { + // given an established MLS 1:1 linked to its one-on-one user, with the other member already + // absent — as the backend leaves it for the party that was blocked (and never notified). + let qualifiedID = QualifiedID(uuid: .create(), domain: owningDomain) + await syncMOC.perform { [self] in + let conversation = ZMConversation.insertNewObject(in: syncMOC) + conversation.remoteIdentifier = qualifiedID.uuid + conversation.domain = qualifiedID.domain + conversation.conversationType = .oneOnOne + conversation.messageProtocol = .mls + conversation.mlsStatus = .ready + conversation.oneOnOneUser = otherUser + conversation.addParticipantAndUpdateConversationState( + user: ZMUser.selfUser(in: syncMOC), + role: nil + ) + } + + let payload = await syncMOC.perform { [self] in + let selfUser = ZMUser.selfUser(in: syncMOC) + let selfMember = Payload.ConversationMember(qualifiedID: selfUser.qualifiedID!) + let members = Payload.ConversationMembers(selfMember: selfMember, others: []) + return Payload.Conversation( + qualifiedID: qualifiedID, + type: BackendConversationType.oneOnOne.rawValue, + members: members + ) + } + + // when the 1:1 is synced without the other member + await sut.updateOrCreateConversation( + from: payload, + in: syncMOC + ) + + // then the established MLS 1:1 is preserved (not wiped, not read-only) + try await syncMOC.perform { [self] in + let conversation = try XCTUnwrap(ZMConversation.fetch(with: qualifiedID, in: syncMOC)) + XCTAssertEqual(conversation.mlsStatus, .ready) + XCTAssertEqual(conversation.messageProtocol, .mls) + XCTAssertFalse(conversation.isForcedReadOnly) + XCTAssertEqual(conversation.oneOnOneUser, otherUser) + } + } + func testUpdateOrCreateConversation_OneToOne_CreatesConversation() async throws { // given sut = ConversationEventPayloadProcessor( diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_21_0.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_22_0.swift similarity index 96% rename from wire-ios-sync-engine/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_21_0.swift rename to wire-ios-sync-engine/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_22_0.swift index 2b1109fabce..7762686ee22 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_21_0.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_22_0.swift @@ -28,9 +28,9 @@ import WireLogging /// `GET /conversations/{id}` for a 1:1 once the connection is blocked, and the work item /// blindly deleted the local conversation in that case. This migration restores those /// conversations so the user can see and unblock them from the conversation list. -struct AppVersionMigration_4_21_0: AppVersionMigration { +struct AppVersionMigration_4_22_0: AppVersionMigration { - let version: SemanticVersion = "4.21.0" + let version: SemanticVersion = "4.22.0" let coreDataStack: CoreDataStackProtocol func perform() async throws { diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift index 28f76a3cdfa..899bacb39fb 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift @@ -1648,7 +1648,7 @@ extension ZMUserSession { AppVersionMigration_4_18_0( coreDataStack: coreDataStack ), - AppVersionMigration_4_21_0( + AppVersionMigration_4_22_0( coreDataStack: coreDataStack ) ] diff --git a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_21_0Tests.swift b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_22_0Tests.swift similarity index 96% rename from wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_21_0Tests.swift rename to wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_22_0Tests.swift index 410bcfffeab..83f4e022e8d 100644 --- a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_21_0Tests.swift +++ b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSession/AppVersionMigrations/AppVersionMigration_4_22_0Tests.swift @@ -22,17 +22,17 @@ import WireDataModelSupport @testable import WireSyncEngine -struct AppVersionMigration_4_21_0Tests { +struct AppVersionMigration_4_22_0Tests { let coreDataHelper = CoreDataStackHelper() let modelHelper = ModelHelper() let stack: CoreDataStack - let sut: AppVersionMigration_4_21_0 + let sut: AppVersionMigration_4_22_0 init() async throws { self.stack = try await coreDataHelper.createStack() - self.sut = AppVersionMigration_4_21_0(coreDataStack: stack) + self.sut = AppVersionMigration_4_22_0(coreDataStack: stack) } @Test("Restores blocked 1:1 conversation that was marked as deleted remotely") diff --git a/wire-ios/Wire-iOS Tests/ConversationViewControllerSnapshotTests.swift b/wire-ios/Wire-iOS Tests/ConversationViewControllerSnapshotTests.swift index 12301fac65e..acef0ce17f7 100644 --- a/wire-ios/Wire-iOS Tests/ConversationViewControllerSnapshotTests.swift +++ b/wire-ios/Wire-iOS Tests/ConversationViewControllerSnapshotTests.swift @@ -29,6 +29,7 @@ final class ConversationViewControllerSnapshotTests: ZMSnapshotTestCase, CoreDat private var sut: ConversationViewController! private var serviceUser: ZMUser! private var userSession: UserSessionMock! + var getParticipantImageSourceUseCase: MockGetParticipantImageSourceUseCaseProtocol! var coreDataFixture: CoreDataFixture! var snapshotHelper: SnapshotHelper! @@ -56,6 +57,7 @@ final class ConversationViewControllerSnapshotTests: ZMSnapshotTestCase, CoreDat sut = nil serviceUser = nil coreDataFixture = nil + getParticipantImageSourceUseCase = nil super.tearDown() } @@ -201,6 +203,13 @@ extension ConversationViewControllerSnapshotTests { uiMOC! } + getParticipantImageSourceUseCase = MockGetParticipantImageSourceUseCaseProtocol() + getParticipantImageSourceUseCase.invokeUser_MockMethod = { [uiMOC] user in + await uiMOC.perform { + .text(user.initials ?? "") + } + } + sut = ConversationViewController( conversation: conversation, visibleMessage: nil, @@ -211,7 +220,7 @@ extension ConversationViewControllerSnapshotTests { mediaPlaybackManager: .init(name: nil, userSession: userSession), classificationProvider: nil, networkStatusObservable: MockNetworkStatusObservable(), - getParticipantImageSourceUseCase: MockGetParticipantImageSourceUseCaseProtocol(), + getParticipantImageSourceUseCase: getParticipantImageSourceUseCase, wireMessagingFactory: MockWireMessagingFactoryProtocol.makeDefault() ) } @@ -237,3 +246,21 @@ extension ConversationViewControllerSnapshotTests { } } + +// MARK: - Blocked user + +extension ConversationViewControllerSnapshotTests { + + func testThatBlockedUserBarReplacesInputBar_WhenSelfBlockedTheOtherUser() { + // given + let mockConversation = createOneOnOneConversation(.blocked) + + // when + createSut(conversation: mockConversation) + + // then + XCTAssertTrue(sut.didBlockConnectedUser) + snapshotHelper.verify(matching: sut) + } + +} diff --git a/wire-ios/Wire-iOS Tests/OneOnOneConversationHeaderViewSnapshotTests.swift b/wire-ios/Wire-iOS Tests/OneOnOneConversationHeaderViewSnapshotTests.swift index 32cda13eb46..2288dbfa62f 100644 --- a/wire-ios/Wire-iOS Tests/OneOnOneConversationHeaderViewSnapshotTests.swift +++ b/wire-ios/Wire-iOS Tests/OneOnOneConversationHeaderViewSnapshotTests.swift @@ -67,10 +67,16 @@ final class OneOnOneConversationHeaderViewSnapshotTests: XCTestCase { // MARK: - Helper Method - func sutForUser(_ mockUser: MockUserType, isFederated: Bool = false) -> OneOnOneConversationHeaderView { - mockUser.isPendingApprovalByOtherUser = true + func sutForUser( + _ mockUser: MockUserType, + isFederated: Bool = false, + isPendingApproval: Bool = false, + isBlocked: Bool = false + ) -> OneOnOneConversationHeaderView { + mockUser.isPendingApprovalByOtherUser = isPendingApproval mockUser.isPendingApprovalBySelfUser = false - mockUser.isConnected = false + mockUser.isConnected = !isPendingApproval && !isBlocked + mockUser.isBlocked = isBlocked mockUser.isFederated = isFederated mockUser.domain = "wire.com" @@ -94,11 +100,18 @@ final class OneOnOneConversationHeaderViewSnapshotTests: XCTestCase { } func testWithoutUserName() { - // The last mock user does not have a handle + // The last mock user does not have a handle. For a connected (non-pending) user the avatar + // shows the user's initials rather than a connection-state badge. mockUser = SwiftMockLoader.mockUsers().last! sut = sutForUser(mockUser) sut.layoutForTest() snapshotHelper.verify(matching: sut) } + func testBlockedUser() { + sut = sutForUser(mockUser, isBlocked: true) + sut.layoutForTest() + snapshotHelper.verify(matching: sut) + } + } diff --git a/wire-ios/Wire-iOS Tests/ReferenceImages/ConversationViewControllerSnapshotTests/testThatBlockedUserBarReplacesInputBar_WhenSelfBlockedTheOtherUser.1.png b/wire-ios/Wire-iOS Tests/ReferenceImages/ConversationViewControllerSnapshotTests/testThatBlockedUserBarReplacesInputBar_WhenSelfBlockedTheOtherUser.1.png new file mode 100644 index 00000000000..f17a14536f9 --- /dev/null +++ b/wire-ios/Wire-iOS Tests/ReferenceImages/ConversationViewControllerSnapshotTests/testThatBlockedUserBarReplacesInputBar_WhenSelfBlockedTheOtherUser.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:697fb7b20c05a206aa77c22f2aa3de0b91407b098d4baeb1d11b2898d5925148 +size 77740 diff --git a/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testBlockedUser.1.png b/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testBlockedUser.1.png new file mode 100644 index 00000000000..b2d7893a8a8 --- /dev/null +++ b/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testBlockedUser.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00e0dc94c6a1224288e6f835398358de50574df6955b873432286fafdfc23e1a +size 121595 diff --git a/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithUserName.1.png b/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithUserName.1.png index ea480a12924..eb6175617fe 100644 --- a/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithUserName.1.png +++ b/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithUserName.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe8eda15df0bca1ebd560e986b0f612059823f081edd675d2a33b902415aa808 -size 104537 +oid sha256:1ad1264c96339bb6739b1eb6cde2f428f56a82b5ecc3824c5da3875fd7d77ff6 +size 106620 diff --git a/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithUserName_Federated.1.png b/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithUserName_Federated.1.png index 0a06dadaa1f..c939729e9ea 100644 --- a/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithUserName_Federated.1.png +++ b/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithUserName_Federated.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49641fae6c22da833fadcd97797cdd43839d660adeb9f50b162f2dabc16ae666 -size 106351 +oid sha256:0949e4009a292b6243ad33e7aee20d15a57d4c9454e72b60f33873c7b12bf550 +size 108414 diff --git a/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithoutUserName.1.png b/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithoutUserName.1.png index 07073330679..52e5a169028 100644 --- a/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithoutUserName.1.png +++ b/wire-ios/Wire-iOS Tests/ReferenceImages/OneOnOneConversationHeaderViewSnapshotTests/testWithoutUserName.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70ba37e2b538a9ee8a920426b7ec4daa169e973141e099dbca3875f32fc31714 -size 98399 +oid sha256:63c60a41c3b886c87654b84f48146062293cf4faf2a498255043ce937de31564 +size 101108 diff --git a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift index 65524ec88bf..cd689b43135 100644 --- a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift +++ b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift @@ -2749,6 +2749,8 @@ internal enum L10n { } } internal enum InputBar { + /// You blocked this user + internal static let blockedUser = L10n.tr("Localizable", "conversation.input_bar.blocked_user", fallback: "You blocked this user") /// Cancel reply internal static let closeReply = L10n.tr("Localizable", "conversation.input_bar.close_reply", fallback: "Cancel reply") /// Type a message diff --git a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings index 3c22c87aaad..3319c02a8af 100644 --- a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings +++ b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings @@ -317,6 +317,7 @@ "conversation.input_bar.verified" = "Verified"; "conversation.input_bar.placeholder" = "Type a message"; "conversation.input_bar.placeholder_ephemeral" = "Self-deleting message"; +"conversation.input_bar.blocked_user" = "You blocked this user"; "conversation.input_bar.audio_message.tooltip.pull_send" = "Swipe up to send"; "conversation.input_bar.audio_message.tooltip.tap_send" = "Tap to send"; diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/ConnectRequests/OneOnOneConversationHeaderView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/ConnectRequests/OneOnOneConversationHeaderView.swift index 3d9dbb4b38b..019f688095b 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/ConnectRequests/OneOnOneConversationHeaderView.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/ConnectRequests/OneOnOneConversationHeaderView.swift @@ -35,7 +35,15 @@ final class OneOnOneConversationHeaderView: UIView, Copyable { private let firstLabel = UILabel() private let secondLabel = UILabel() private let labelContainer = UIStackView(axis: .vertical) - private let userImageView = UserImageView() + private let userImageView: BadgeUserImageView = { + let view = BadgeUserImageView() + // Match the conversation list: show the full-colour image (or the blocked badge) rather + // than a desaturated avatar for a blocked user. + view.shouldDesaturate = false + view.badgeIconSize = .custom(130) + return view + }() + private let guestIndicator = LabelIndicator(context: .guest) private let guestWarningView = GuestAccountWarningView() private let guestWarningContainer = UIView() diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/BlockedUserBottomBarViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/BlockedUserBottomBarViewController.swift new file mode 100644 index 00000000000..ada42a9f674 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/BlockedUserBottomBarViewController.swift @@ -0,0 +1,105 @@ +// +// Wire +// Copyright (C) 2026 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 UIKit +import WireDesign + +/// Replaces the input bar in a one-on-one conversation when the self user has blocked the other +/// user, indicating that messages can no longer be sent. See `ConversationViewController`. +final class BlockedUserBottomBarViewController: UIViewController { + + private static let verticalInset: CGFloat = 12 + + private lazy var separator = UIView() + private lazy var iconImageView = UIImageView() + private lazy var label = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + createConstraints() + } + + private func setupViews() { + view.backgroundColor = ColorTheme.Backgrounds.surface + + separator.backgroundColor = ColorTheme.Strokes.dividersOutlineVariant + + iconImageView.contentMode = .scaleAspectFit + iconImageView.setContentHuggingPriority(.required, for: .horizontal) + iconImageView.image = StyleKitIcon.about.makeImage(size: .tiny, color: ColorTheme.Base.secondaryText) + + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + label.attributedText = Self.makeAttributedText() + label.accessibilityIdentifier = "BlockedUserBottomBar.label" + + [separator, iconImageView, label].forEach(view.addSubview) + } + + private func createConstraints() { + [separator, iconImageView, label].forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + let safeArea = view.safeAreaLayoutGuide + NSLayoutConstraint.activate([ + separator.leadingAnchor.constraint(equalTo: view.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: view.trailingAnchor), + separator.topAnchor.constraint(equalTo: view.topAnchor), + separator.heightAnchor.constraint(equalToConstant: 1), + + iconImageView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 16), + iconImageView.centerYAnchor.constraint(equalTo: label.centerYAnchor), + + label.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 8), + label.trailingAnchor.constraint(lessThanOrEqualTo: safeArea.trailingAnchor, constant: -16), + label.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: Self.verticalInset), + label.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -Self.verticalInset) + ]) + } + + /// Builds "You blocked this user" with the leading subject word emphasised (darker, semibold) + /// and the remainder in the secondary text colour, mirroring the design on the other platforms. + private static func makeAttributedText() -> NSAttributedString { + let text = L10n.Localizable.Conversation.InputBar.blockedUser + + let metrics = UIFontMetrics(forTextStyle: .subheadline) + let regularFont = metrics.scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + let emphasizedFont = metrics.scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + + let attributedText = NSMutableAttributedString( + string: text, + attributes: [ + .font: regularFont, + .foregroundColor: ColorTheme.Base.secondaryText + ] + ) + + if let subjectRange = text.range(of: "^\\S+", options: .regularExpression) { + attributedText.addAttributes( + [ + .font: emphasizedFont, + .foregroundColor: ColorTheme.Backgrounds.onSurface + ], + range: NSRange(subjectRange, in: text) + ) + } + + return attributedText + } + +} 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 82413242c6e..77c8bcbf330 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 @@ -56,9 +56,12 @@ final class ConversationSenderMessageDetailsCell: UIView, ConversationMessageCel var isSelected: Bool = false private lazy var avatar: UserImageView = { - let view = UserImageView() + let view = BadgeUserImageView() view.initialsFont = .avatarInitial view.size = .badge + // Match the conversation list: show the full-colour image (or the blocked badge) rather + // than a desaturated avatar for a blocked user. + view.shouldDesaturate = false view.translatesAutoresizingMaskIntoConstraints = false view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tappedOnAvatar))) view.accessibilityElementsHidden = false diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController+Constraints.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController+Constraints.swift index 6c23ba6e7e5..bfac461ba7e 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController+Constraints.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController+Constraints.swift @@ -49,6 +49,48 @@ extension ConversationViewController { } } + /// Whether the self user has blocked the one-on-one partner. When `true`, the input bar is + /// replaced by a "You blocked this user" bar, since messages can no longer be sent. Note this is + /// only the case for the user who did the blocking — the blocked party is never notified. + var didBlockConnectedUser: Bool { + conversation.conversationType == .oneOnOne && conversation.connectedUser?.isBlocked == true + } + + func updateBlockedUserVisibility() { + if didBlockConnectedUser { + guard blockedUserViewController == nil else { return } + + // The input bar is collapsed in `updateInputBarVisibility`; pin the content above this + // (shorter) bar instead so it occupies its own compact height at the bottom. + let blockedUserViewController = BlockedUserBottomBarViewController() + blockedUserViewController.view.translatesAutoresizingMaskIntoConstraints = false + addChild(blockedUserViewController) + view.addSubview(blockedUserViewController.view) + + contentBottomToInputBar?.isActive = false + NSLayoutConstraint.activate([ + blockedUserViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + blockedUserViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + blockedUserViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + exchangeableContentViewController.view.bottomAnchor + .constraint(equalTo: blockedUserViewController.view.topAnchor) + ]) + blockedUserViewController.didMove(toParent: self) + self.blockedUserViewController = blockedUserViewController + } else { + guard blockedUserViewController != nil else { return } + + blockedUserViewController?.willMove(toParent: nil) + blockedUserViewController?.view.removeFromSuperview() + blockedUserViewController?.removeFromParent() + blockedUserViewController = nil + + // Restore the content → input bar pin (removing the bar's view already dropped the + // content → blocked bar constraint). + contentBottomToInputBar?.isActive = true + } + } + func createConstraints() { [ conversationBarController.view, @@ -66,8 +108,9 @@ extension ConversationViewController { exchangeableContentViewController.view.topAnchor.constraint(equalTo: conversationBottomAnchor) ]) - exchangeableContentViewController.view.bottomAnchor.constraint(equalTo: inputBarController.view.topAnchor) - .isActive = true + contentBottomToInputBar = exchangeableContentViewController.view.bottomAnchor + .constraint(equalTo: inputBarController.view.topAnchor) + contentBottomToInputBar?.isActive = true NSLayoutConstraint.activate([ inputBarController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), inputBarController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift index 0de937e5772..78b01a98c36 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift @@ -93,6 +93,7 @@ final class ConversationViewController: UIViewController { var collectionController: CollectionsViewController? var outgoingConnectionViewController: OutgoingConnectionViewController! + var blockedUserViewController: BlockedUserBottomBarViewController? let conversationBarController: BarController = .init() let guestsBarController: GuestsBarController = .init() let invisibleInputAccessoryView: InvisibleInputAccessoryView = .init() @@ -104,6 +105,9 @@ final class ConversationViewController: UIViewController { var inputBarBottomMargin: NSLayoutConstraint? var inputBarZeroHeight: NSLayoutConstraint? + /// Pins the content view above the input bar. Deactivated while the "You blocked this user" + /// bar is shown, so the content sits above that (shorter) bar instead. + var contentBottomToInputBar: NSLayoutConstraint? var isAppearing = false private var voiceChannelStateObserverToken: Any? @@ -307,6 +311,7 @@ final class ConversationViewController: UIViewController { updateOutgoingConnectionVisibility() createConstraints() updateInputBarVisibility() + updateBlockedUserVisibility() if let quote = conversation.draftMessage?.quote, !quote.hasBeenDeleted, let contentViewController { let messageReplyAttachmentsViewModel = MessageReplyAttachmentsViewModel( @@ -461,13 +466,18 @@ final class ConversationViewController: UIViewController { } private func updateInputBarVisibility() { - if conversation.isReadOnly { + // Collapse the input bar when the conversation is read-only or when the self user has + // blocked the other user (in which case it is replaced by the "You blocked this user" bar). + let shouldCollapseInputBar = conversation.isReadOnly || didBlockConnectedUser + + if shouldCollapseInputBar { inputBarController.inputBar.textView.resignFirstResponder() inputBarController.dismissMentionsIfNeeded() inputBarController.removeReplyComposingView() } - inputBarZeroHeight?.isActive = conversation.isReadOnly + inputBarController.isHiddenForBlockedUser = didBlockConnectedUser + inputBarZeroHeight?.isActive = shouldCollapseInputBar view.setNeedsLayout() } @@ -730,6 +740,7 @@ extension ConversationViewController: ZMConversationObserver { updateOutgoingConnectionVisibility() contentViewController?.updateTableViewHeaderView() updateInputBarVisibility() + updateBlockedUserVisibility() } if note.participantsChanged || diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift index 12ee90f612a..286bcc64b9d 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift @@ -610,8 +610,17 @@ final class ConversationInputBarViewController: UIViewController, updateButtonStates() } + /// Hides the input bar when the self user has blocked the other user, so it can be replaced by + /// the "You blocked this user" bar. See `ConversationViewController`. + var isHiddenForBlockedUser = false { + didSet { + guard isHiddenForBlockedUser != oldValue else { return } + updateInputBarVisibility() + } + } + func updateInputBarVisibility() { - view.isHidden = conversation.isReadOnly + view.isHidden = conversation.isReadOnly || isHiddenForBlockedUser } @objc diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/UseCases/GetParticipantImageSourceUseCase.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/UseCases/GetParticipantImageSourceUseCase.swift index 3e71727c1d5..d5b97c7c9d5 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/UseCases/GetParticipantImageSourceUseCase.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/UseCases/GetParticipantImageSourceUseCase.swift @@ -16,8 +16,10 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // +import UIKit import WireAccountImageUI import WireDataModel +import WireDesign import WireSyncEngine class GetParticipantImageSourceUseCase: GetParticipantImageSourceUseCaseProtocol { @@ -30,6 +32,13 @@ class GetParticipantImageSourceUseCase: GetParticipantImageSourceUseCaseProtocol @MainActor func invoke(user: UserType) async -> WireAccountImageUI.AccountImageSource? { + // A blocked user shows the blocked badge as its avatar everywhere, matching the conversation + // list (see `BadgeUserImageView`). `AccountImageView` has no badge support, so we hand it a + // composed image: a black circle with the white block glyph centred (no accent tint). + if user.isBlocked { + return .image(Self.makeBlockedImage()) + } + let image = await repository.invoke(user: user) if let image { return WireAccountImageUI.AccountImageSource.image(image) @@ -37,4 +46,21 @@ class GetParticipantImageSourceUseCase: GetParticipantImageSourceUseCaseProtocol return WireAccountImageUI.AccountImageSource.text(user.initials ?? "") } } + + private static func makeBlockedImage() -> UIImage { + let side: CGFloat = 40 + let glyphSide = side * 0.5 + let glyph = StyleKitIcon.block.makeImage(size: .custom(glyphSide), color: .white) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side)) + return renderer.image { context in + UIColor.black.setFill() + context.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: side, height: side)) + glyph.draw(in: CGRect( + x: (side - glyphSide) / 2, + y: (side - glyphSide) / 2, + width: glyphSide, + height: glyphSide + )) + } + } }