From 4f92e600af400602a1af0ba20feaedf5983829ad Mon Sep 17 00:00:00 2001 From: PatrykBuniX Date: Mon, 29 Apr 2024 15:20:18 +0200 Subject: [PATCH 1/5] refactor: update conversaiton readonly state --- src/script/conversation/ConversationRepository.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index e7cd02f98ed..d897766396d 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -1503,14 +1503,6 @@ export class ConversationRepository { } }; - private readonly updateConversationReadOnlyState = async ( - conversationEntity: Conversation, - conversationReadOnlyState: CONVERSATION_READONLY_STATE | null, - ) => { - conversationEntity.readOnlyState(conversationReadOnlyState); - await this.saveConversationStateInDb(conversationEntity); - }; - private readonly getProtocolFor1to1Conversation = async ( otherUserId: QualifiedId, shouldRefreshUser = false, @@ -1857,16 +1849,13 @@ export class ConversationRepository { // If proteus is not supported by the other user we have to mark conversation as readonly if (!doesOtherUserSupportProteus) { await this.blacklistConversation(proteusConversationId); - await this.updateConversationReadOnlyState( - proteusConversation, - CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_SELF_UNSUPPORTED_MLS, - ); + proteusConversation.readOnlyState(CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_SELF_UNSUPPORTED_MLS); return proteusConversation; } // If proteus is supported by the other user, we just return a proteus conversation and remove readonly state from it. await this.removeConversationFromBlacklist(proteusConversationId); - await this.updateConversationReadOnlyState(proteusConversation, null); + await proteusConversation.readOnlyState(null); return proteusConversation; }; From 68d361dafb3ac4019c667cb6f01a53ac9e1e395f Mon Sep 17 00:00:00 2001 From: PatrykBuniX Date: Mon, 6 May 2024 13:39:00 +0200 Subject: [PATCH 2/5] runfix: optionally do not allow unestablished mls 1:1 --- .../conversation/ConversationRepository.ts | 47 ++++++++++++++----- src/script/view_model/ActionsViewModel.ts | 5 +- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index d897766396d..f425f7d9f57 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -45,6 +45,7 @@ import {BackendErrorLabel} from '@wireapp/api-client/lib/http/'; import type {BackendError} from '@wireapp/api-client/lib/http/'; import type {QualifiedId} from '@wireapp/api-client/lib/user/'; import {MLSCreateConversationResponse} from '@wireapp/core/lib/conversation'; +import {ClientMLSError, ClientMLSErrorLabel} from '@wireapp/core/lib/messagingProtocols/mls'; import {amplify} from 'amplify'; import {StatusCodes as HTTP_STATUS} from 'http-status-codes'; import {container} from 'tsyringe'; @@ -167,11 +168,13 @@ type IncomingEvent = ConversationEvent | ClientConversationEvent; export enum CONVERSATION_READONLY_STATE { READONLY_ONE_TO_ONE_SELF_UNSUPPORTED_MLS = 'READONLY_ONE_TO_ONE_SELF_UNSUPPORTED_MLS', READONLY_ONE_TO_ONE_OTHER_UNSUPPORTED_MLS = 'READONLY_ONE_TO_ONE_OTHER_UNSUPPORTED_MLS', + READONLY_ONE_TO_ONE_NO_KEY_PACKAGES = 'READONLY_ONE_TO_ONE_NO_KEY_PACKAGES', } interface GetInitialised1To1ConversationOptions { isLiveUpdate?: boolean; shouldRefreshUser?: boolean; + mls?: {allowUnestablished?: boolean}; } export class ConversationRepository { @@ -1325,7 +1328,11 @@ export class ConversationRepository { * We have to add a delay to make sure the welcome message is not wasted, in case the self client would establish mls group themselves before receiving the welcome. */ const shouldDelayMLSGroupEstablishment = options.isLiveUpdate && isMLSSupportedByTheOtherUser; - return this.initMLS1to1Conversation(userId, isMLSSupportedByTheOtherUser, shouldDelayMLSGroupEstablishment); + return this.initMLS1to1Conversation(userId, { + isMLSSupportedByTheOtherUser, + shouldDelayGroupEstablishment: shouldDelayMLSGroupEstablishment, + allowUnestablished: options.mls?.allowUnestablished, + }); } // There's no connection so it's a proteus conversation with a team member @@ -1740,8 +1747,11 @@ export class ConversationRepository { */ private readonly initMLS1to1Conversation = async ( otherUserId: QualifiedId, - isMLSSupportedByTheOtherUser: boolean, - shouldDelayGroupEstablishment = false, + { + isMLSSupportedByTheOtherUser, + shouldDelayGroupEstablishment = false, + allowUnestablished = true, + }: {isMLSSupportedByTheOtherUser: boolean; shouldDelayGroupEstablishment?: boolean; allowUnestablished?: boolean}, ): Promise => { // When receiving some live updates via websocket, e.g. after connection request is accepted, both sides (users) of connection will react to conversation status update event. // We want to reduce the possibility of two users trying to establish an MLS group at the same time. @@ -1800,11 +1810,24 @@ export class ConversationRepository { throw new Error('Self user is not available!'); } - const initialisedMLSConversation = await this.establishMLS1to1Conversation(mlsConversation, otherUserId); + let initialisedMLSConversation: MLSConversation = mlsConversation; + + try { + initialisedMLSConversation = await this.establishMLS1to1Conversation(mlsConversation, otherUserId); + initialisedMLSConversation.readOnlyState(null); + } catch (error) { + this.logger.warn(`Failed to establish MLS 1:1 conversation with user ${otherUserId.id}`, error); + if (!allowUnestablished) { + throw error; + } + + if (error instanceof ClientMLSError && error.label === ClientMLSErrorLabel.NO_KEY_PACKAGES_AVAILABLE) { + initialisedMLSConversation.readOnlyState(CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_NO_KEY_PACKAGES); + } + } // If mls is supported by the other user, we can establish the group and remove readonly state from the conversation. - initialisedMLSConversation.readOnlyState(null); - await this.update1To1ConversationParticipants(mlsConversation, otherUserId); + await this.update1To1ConversationParticipants(initialisedMLSConversation, otherUserId); await this.saveConversation(initialisedMLSConversation); if (shouldOpenMLS1to1Conversation) { @@ -1964,11 +1987,10 @@ export class ConversationRepository { `Connection with user ${otherUserId.id} is accepted, using protocol ${protocol} for 1:1 conversation`, ); if (protocol === ConversationProtocol.MLS || localMLSConversation) { - return this.initMLS1to1Conversation( - otherUserId, + return this.initMLS1to1Conversation(otherUserId, { isMLSSupportedByTheOtherUser, - shouldDelayMLSGroupEstablishment, - ); + shouldDelayGroupEstablishment: shouldDelayMLSGroupEstablishment, + }); } if (protocol === ConversationProtocol.PROTEUS) { @@ -1985,7 +2007,10 @@ export class ConversationRepository { this.logger.log( `Connection with user ${otherUserId.id} is not accepted, using already known MLS 1:1 conversation ${localMLSConversation.id}`, ); - return this.initMLS1to1Conversation(otherUserId, isMLSSupportedByTheOtherUser, shouldDelayMLSGroupEstablishment); + return this.initMLS1to1Conversation(otherUserId, { + isMLSSupportedByTheOtherUser, + shouldDelayGroupEstablishment: shouldDelayMLSGroupEstablishment, + }); } this.logger.log( diff --git a/src/script/view_model/ActionsViewModel.ts b/src/script/view_model/ActionsViewModel.ts index 27858307424..89caef844a7 100644 --- a/src/script/view_model/ActionsViewModel.ts +++ b/src/script/view_model/ActionsViewModel.ts @@ -336,7 +336,10 @@ export class ActionsViewModel { }; getOrCreate1to1Conversation = async (userEntity: User): Promise => { - const conversationEntity = await this.conversationRepository.getInitialised1To1Conversation(userEntity.qualifiedId); + const conversationEntity = await this.conversationRepository.getInitialised1To1Conversation( + userEntity.qualifiedId, + {mls: {allowUnestablished: false}}, + ); if (conversationEntity) { return conversationEntity; } From 304cd868d7b3b0d39c1fbf7f51ca875969d5a927 Mon Sep 17 00:00:00 2001 From: PatrykBuniX Date: Tue, 7 May 2024 12:12:31 +0200 Subject: [PATCH 3/5] feat: add a placeholder message for readonly 1:1 with a user without keys --- src/i18n/en-US.json | 1 + .../ReadOnlyConversationMessage.test.tsx | 10 ++++++++++ .../ReadOnlyConversationMessage.tsx | 6 ++++++ 3 files changed, 17 insertions(+) diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index 34441554b7f..1823d08a5ba 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -1167,6 +1167,7 @@ "ongoingGroupVideoCall": "Ongoing video conference call with {{conversationName}}, your camera is {{cameraStatus}}.", "ongoingVideoCall": "Ongoing video call with {{conversationName}}, your camera is {{cameraStatus}}.", "otherUserNotSupportMLSMsg": "You can't communicate with {{participantName}} anymore, as you two now use different protocols. When {{participantName}} gets an update, you can call and send messages and files again.", + "otherUserNoAvailableKeyPackages": "This user has no available keys.", "participantDevicesDetailHeadline": "Verify that this matches the fingerprint shown on [bold]{{user}}’s device[/bold].", "participantDevicesDetailHowTo": "How do I do that?", "participantDevicesDetailResetSession": "Reset session", diff --git a/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.test.tsx b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.test.tsx index 45dd4b990f6..91f0b4a88f7 100644 --- a/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.test.tsx +++ b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.test.tsx @@ -91,4 +91,14 @@ describe('ReadOnlyConversationMessage', () => { expect(reloadAppMock).toHaveBeenCalled(); }); + + it("renders a conversation with a user that don't have any key pakages available", () => { + const conversation = generateConversation(CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_NO_KEY_PACKAGES, true); + + const {getByText} = render( + withTheme( {}} conversation={conversation} />), + ); + + expect(getByText('otherUserNoAvailableKeyPackages')).toBeDefined(); + }); }); diff --git a/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx index acf67ecec51..2c7ea4a4a03 100644 --- a/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx +++ b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx @@ -87,6 +87,12 @@ export const ReadOnlyConversationMessage: FC = ); + case CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_NO_KEY_PACKAGES: + return ( + + {t('otherUserNoAvailableKeyPackages')} + + ); } } From 8d27e8ad7a4e07a8ce5f6a7e240846d8bf6b7e1e Mon Sep 17 00:00:00 2001 From: PatrykBuniX Date: Wed, 15 May 2024 15:38:17 +0200 Subject: [PATCH 4/5] runfix: update copy --- src/i18n/en-US.json | 2 +- .../ReadOnlyConversationMessage.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index 1823d08a5ba..bc0c1ef6bbe 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -1167,7 +1167,7 @@ "ongoingGroupVideoCall": "Ongoing video conference call with {{conversationName}}, your camera is {{cameraStatus}}.", "ongoingVideoCall": "Ongoing video call with {{conversationName}}, your camera is {{cameraStatus}}.", "otherUserNotSupportMLSMsg": "You can't communicate with {{participantName}} anymore, as you two now use different protocols. When {{participantName}} gets an update, you can call and send messages and files again.", - "otherUserNoAvailableKeyPackages": "This user has no available keys.", + "otherUserNoAvailableKeyPackages": "You can't communicate with {{participantName}} at the moment. When {{participantName}} logs in again, you can call and send messages and files again.", "participantDevicesDetailHeadline": "Verify that this matches the fingerprint shown on [bold]{{user}}’s device[/bold].", "participantDevicesDetailHowTo": "How do I do that?", "participantDevicesDetailResetSession": "Reset session", diff --git a/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx index 2c7ea4a4a03..8c3783f66d6 100644 --- a/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx +++ b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx @@ -90,7 +90,14 @@ export const ReadOnlyConversationMessage: FC = case CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_NO_KEY_PACKAGES: return ( - {t('otherUserNoAvailableKeyPackages')} + + {replaceReactComponents(t('otherUserNoAvailableKeyPackages'), [ + { + exactMatch: '{{participantName}}', + render: () => {user.name()}, + }, + ])} + ); } From 86ac8ce4c7ca6875477c18725253a5ccce20e5eb Mon Sep 17 00:00:00 2001 From: PatrykBuniX Date: Thu, 16 May 2024 11:00:22 +0200 Subject: [PATCH 5/5] test: readonly mls 1:1 no available keys --- .../ConversationRepository.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/script/conversation/ConversationRepository.test.ts b/src/script/conversation/ConversationRepository.test.ts index 9c26d856aca..65522bab1a5 100644 --- a/src/script/conversation/ConversationRepository.test.ts +++ b/src/script/conversation/ConversationRepository.test.ts @@ -41,6 +41,7 @@ import { } from '@wireapp/api-client/lib/event/'; import {BackendError, BackendErrorLabel} from '@wireapp/api-client/lib/http'; import {QualifiedId} from '@wireapp/api-client/lib/user'; +import {ClientMLSError, ClientMLSErrorLabel} from '@wireapp/core/lib/messagingProtocols/mls'; import {amplify} from 'amplify'; import {StatusCodes as HTTP_STATUS} from 'http-status-codes'; import ko from 'knockout'; @@ -915,6 +916,55 @@ describe('ConversationRepository', () => { ); }); + it('marks mls 1:1 conversation as read-only if both users support mls but the other user has no keys available', async () => { + const conversationRepository = testFactory.conversation_repository!; + const userRepository = testFactory.user_repository!; + + const otherUserId = {id: 'a718410c-3833-479d-bd80-a5df03f38414', domain: 'test-domain'}; + const otherUser = new User(otherUserId.id, otherUserId.domain); + otherUser.supportedProtocols([ConversationProtocol.MLS]); + userRepository['userState'].users.push(otherUser); + + const selfUserId = {id: '1a9da9ca-a495-47a8-ac70-9ffbe924b2d0', domain: 'test-domain'}; + const selfUser = new User(selfUserId.id, selfUserId.domain); + selfUser.supportedProtocols([ConversationProtocol.MLS]); + jest.spyOn(conversationRepository['userState'], 'self').mockReturnValue(selfUser); + + const mls1to1ConversationResponse = generateAPIConversation({ + id: {id: '0aab891e-ccf1-4dba-9d74-bacec64b5b1e', domain: 'test-domain'}, + type: CONVERSATION_TYPE.ONE_TO_ONE, + protocol: ConversationProtocol.MLS, + overwites: {group_id: 'groupId'}, + }) as BackendMLSConversation; + + const noKeysError = new ClientMLSError(ClientMLSErrorLabel.NO_KEY_PACKAGES_AVAILABLE); + + jest + .spyOn(container.resolve(Core).service!.conversation, 'establishMLS1to1Conversation') + .mockRejectedValueOnce(noKeysError); + + const [mls1to1Conversation] = conversationRepository.mapConversations([mls1to1ConversationResponse]); + + const connection = new ConnectionEntity(); + connection.conversationId = mls1to1Conversation.qualifiedId; + connection.userId = otherUserId; + otherUser.connection(connection); + mls1to1Conversation.connection(connection); + + conversationRepository['conversationState'].conversations.push(mls1to1Conversation); + + jest + .spyOn(conversationRepository['conversationService'], 'isMLSGroupEstablishedLocally') + .mockResolvedValueOnce(false); + + const conversationEntity = await conversationRepository.getInitialised1To1Conversation(otherUser.qualifiedId); + + expect(conversationEntity?.serialize()).toEqual(mls1to1Conversation.serialize()); + expect(conversationEntity?.readOnlyState()).toEqual( + CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_NO_KEY_PACKAGES, + ); + }); + it('deos not mark mls 1:1 conversation as read-only if the other user does not support mls but mls 1:1 was already established', async () => { const conversationRepository = testFactory.conversation_repository!; const userRepository = testFactory.user_repository!;