diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index 1255ce270eb7..8555587a4bfd 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -731,9 +731,13 @@ "messageFailedToSendWillReceivePlural": "will get your message later.", "messageFailedToSendWillReceiveSingular": "will get your message later.", "messageReactionDetails": "{{emojiCount}} reaction, react with {{emojiName}} emoji", - "mlsConversationRecovered": "You haven’t used this device for a while, or an issue has occurred. Some older messages may not appear here.", + "mlsConversationRecovered": "You haven't used this device for a while, or an issue has occurred. Some older messages may not appear here.", "mlsToggleInfo": "When this is on, conversation will use the new messaging layer security (MLS) protocol.", "mlsToggleName": "MLS", + "selfNotSupportMLSMsgPart1": "You can't communicate with [bold]{{selfUserName}}[/bold] anymore, as your device doesn't support the suitable protocol.", + "downloadLatestMLS": "Download the latest MLS Wire version ", + "selfNotSupportMLSMsgPart2": " to call, and send messages and files again.", + "otherUserNotSupportMLSMsg": "You can't communicate with [bold]{{participantName}}[/bold] anymore, as you two now use different protocols. When [bold]{{participantName}}[/bold] gets an update, you can call and send messages and files again.", "modalAccountCreateAction": "OK", "modalAccountCreateHeadline": "Create an account?", "modalAccountCreateMessage": "By creating an account you will lose the conversation history in this guest room.", diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index 51e075fc7b8d..8cd3af39be39 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -35,6 +35,7 @@ import {showWarningModal} from 'Components/Modals/utils/showWarningModal'; import {TitleBar} from 'Components/TitleBar'; import {CallState} from 'src/script/calling/CallState'; import {Config} from 'src/script/Config'; +import {CONVERSATION_READONLY_STATE} from 'src/script/conversation/ConversationRepository'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {allowsAllFiles, getFileExtensionOrName, hasAllowedExtension} from 'Util/FileTypeUtil'; import {isHittingUploadLimit} from 'Util/isHittingUploadLimit'; @@ -44,6 +45,7 @@ import {safeMailOpen, safeWindowOpen} from 'Util/SanitizationUtil'; import {formatBytes, incomingCssClass, removeAnimationsClass} from 'Util/util'; import {useReadReceiptSender} from './hooks/useReadReceipt'; +import {ReadOnlyConversationMessage} from './ReadOnlyConversationMessage'; import {checkFileSharingPermission} from './utils/checkFileSharingPermission'; import {ConversationState} from '../../conversation/ConversationState'; @@ -71,6 +73,7 @@ interface ConversationProps { readonly userState: UserState; openRightSidebar: (panelState: PanelState, params: RightSidebarParams, compareEntityId?: boolean) => void; isRightSidebarOpen?: boolean; + onRefresh: () => void; } const CONFIG = Config.getConfig(); @@ -81,6 +84,7 @@ export const Conversation: FC = ({ userState, openRightSidebar, isRightSidebarOpen = false, + onRefresh, }) => { const messageListLogger = getLogger('ConversationList'); @@ -99,7 +103,18 @@ export const Conversation: FC = ({ 'classifiedDomains', 'isFileSharingSendingEnabled', ]); - const {is1to1, isRequest} = useKoSubscribableChildren(activeConversation!, ['is1to1', 'isRequest']); + const { + is1to1, + isRequest, + readOnlyState, + display_name: displayName, + } = useKoSubscribableChildren(activeConversation!, ['is1to1', 'isRequest', 'readOnlyState', 'display_name']); + const showReadOnlyConversationMessage = + readOnlyState !== null && + [ + CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_OTHER_UNSUPPORTED_MLS, + CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_SELF_UNSUPPORTED_MLS, + ].includes(readOnlyState); const {self: selfUser} = useKoSubscribableChildren(userState, ['self']); const {inTeam} = useKoSubscribableChildren(selfUser, ['inTeam']); @@ -472,6 +487,7 @@ export const Conversation: FC = ({ callActions={mainViewModel.calling.callActions} openRightSidebar={openRightSidebar} isRightSidebarOpen={isRightSidebarOpen} + isReadOnlyConversation={showReadOnlyConversationMessage} /> {activeCalls.map(call => { @@ -520,22 +536,26 @@ export const Conversation: FC = ({ setMsgElementsFocusable={setMsgElementsFocusable} /> - setMsgElementsFocusable(false)} - uploadDroppedFiles={uploadDroppedFiles} - uploadImages={uploadImages} - uploadFiles={uploadFiles} - /> + {showReadOnlyConversationMessage ? ( + + ) : ( + setMsgElementsFocusable(false)} + uploadDroppedFiles={uploadDroppedFiles} + uploadImages={uploadImages} + uploadFiles={uploadFiles} + /> + )}
diff --git a/src/script/components/Conversation/ReadOnlyConversationMessage.tsx b/src/script/components/Conversation/ReadOnlyConversationMessage.tsx new file mode 100644 index 000000000000..0965fcae9081 --- /dev/null +++ b/src/script/components/Conversation/ReadOnlyConversationMessage.tsx @@ -0,0 +1,77 @@ +/* + * Wire + * Copyright (C) 2023 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 {FC} from 'react'; + +import {Link, LinkVariant} from '@wireapp/react-ui-kit'; + +import {Icon} from 'Components/Icon'; +import {CONVERSATION_READONLY_STATE} from 'src/script/conversation/ConversationRepository'; +import {t} from 'Util/LocalizerUtil'; + +interface ReadOnlyConversationMessageProps { + state: CONVERSATION_READONLY_STATE; + handleMLSUpdate: () => void; + displayName: string; +} +export const ReadOnlyConversationMessage: FC = ({ + state, + handleMLSUpdate, + displayName, +}) => { + const mlsCompatibilityMessage = + state === CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_OTHER_UNSUPPORTED_MLS + ? t('otherUserNotSupportMLSMsg', displayName) + : t('selfNotSupportMLSMsgPart1', displayName); + + return ( +
+
+
+ +
+
+
+ + {state === CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_SELF_UNSUPPORTED_MLS && ( + <> + + {t('downloadLatestMLS')} + + + + )} +
+
+ ); +}; diff --git a/src/script/components/TitleBar/TitleBar.tsx b/src/script/components/TitleBar/TitleBar.tsx index a4c3e82d3513..f2946b03b4aa 100644 --- a/src/script/components/TitleBar/TitleBar.tsx +++ b/src/script/components/TitleBar/TitleBar.tsx @@ -60,6 +60,7 @@ export interface TitleBarProps { teamState: TeamState; isRightSidebarOpen?: boolean; callState?: CallState; + isReadOnlyConversation?: boolean; } export const TitleBar: React.FC = ({ @@ -71,6 +72,7 @@ export const TitleBar: React.FC = ({ userState = container.resolve(UserState), callState = container.resolve(CallState), teamState = container.resolve(TeamState), + isReadOnlyConversation = false, }) => { const {calling: callingRepository} = repositories; const { @@ -297,6 +299,7 @@ export const TitleBar: React.FC = ({ showStartedCallAlert(isGroup, true); }} data-uie-name="do-video-call" + disabled={isReadOnlyConversation} > @@ -313,6 +316,7 @@ export const TitleBar: React.FC = ({ showStartedCallAlert(isGroup); }} data-uie-name="do-call" + disabled={isReadOnlyConversation} > diff --git a/src/script/conversation/ConversationFilter.test.ts b/src/script/conversation/ConversationFilter.test.ts index 29ea2dfd8e9d..e835b45fb637 100644 --- a/src/script/conversation/ConversationFilter.test.ts +++ b/src/script/conversation/ConversationFilter.test.ts @@ -44,6 +44,7 @@ describe('ConversationFilter', () => { accessRoleV2: undefined, access_role: undefined, archived_state: false, + readonly_state: null, archived_timestamp: 0, cipher_suite: 1, cleared_timestamp: 0, @@ -92,6 +93,7 @@ describe('ConversationFilter', () => { accessRoleV2: undefined, access_role: CONVERSATION_LEGACY_ACCESS_ROLE.PRIVATE, archived_state: false, + readonly_state: null, archived_timestamp: 0, cipher_suite: 1, cleared_timestamp: 0, diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index 1a484f456f20..66a03ccd912a 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -153,6 +153,11 @@ type FetchPromise = {rejectFn: (error: ConversationError) => void; resolveFn: (c type EntityObject = {conversationEntity: Conversation; messageEntity: Message}; 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', +} + export class ConversationRepository { private isBlockingNotificationHandling: boolean; private readonly conversationsWithNewEvents: Map; @@ -1381,6 +1386,14 @@ export class ConversationRepository { } }; + private readonly markConversationReadOnly = async ( + conversationEntity: Conversation, + conversationReadOnlyState: CONVERSATION_READONLY_STATE, + ) => { + conversationEntity.readOnlyState(conversationReadOnlyState); + await this.saveConversationStateInDb(conversationEntity); + }; + private readonly getProtocolFor1to1Conversation = async ( otherUserId: QualifiedId, ): Promise<{ @@ -1599,7 +1612,10 @@ export class ConversationRepository { //if mls is not supported by the other user we do not establish the group yet //we just mark the mls conversation as readonly and return it if (!isMLSSupportedByTheOtherUser) { - //TODO: mark conversation as readonly + await this.markConversationReadOnly( + mlsConversation, + CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_OTHER_UNSUPPORTED_MLS, + ); this.logger.info( `MLS 1:1 conversation with user ${otherUserId.id} is not supported by the other user, conversation will become readonly`, ); @@ -1636,7 +1652,10 @@ export class ConversationRepository { // If proteus is not supported by the other user we have to mark conversation as readonly if (!doesOtherUserSupportProteus) { - //TODO: mark conversation as readonly + await this.markConversationReadOnly( + proteusConversation, + CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_SELF_UNSUPPORTED_MLS, + ); } return proteusConversation; diff --git a/src/script/entity/Conversation.ts b/src/script/entity/Conversation.ts index 32ac570a8345..990cceb92747 100644 --- a/src/script/entity/Conversation.ts +++ b/src/script/entity/Conversation.ts @@ -52,7 +52,7 @@ import {ClientRepository} from '../client'; import {Config} from '../Config'; import {ConnectionEntity} from '../connection/ConnectionEntity'; import {ACCESS_STATE} from '../conversation/AccessState'; -import {ConversationRepository} from '../conversation/ConversationRepository'; +import {ConversationRepository, CONVERSATION_READONLY_STATE} from '../conversation/ConversationRepository'; import {isSelfConversation} from '../conversation/ConversationSelectors'; import {ConversationStatus} from '../conversation/ConversationStatus'; import {ConversationVerificationState} from '../conversation/ConversationVerificationState'; @@ -85,6 +85,7 @@ enum TIMESTAMP_TYPE { export class Conversation { private readonly teamState: TeamState; public readonly archivedState: ko.Observable; + public readonly readOnlyState: ko.Observable; private readonly incomingMessages: ko.ObservableArray; private readonly isTeam1to1: ko.PureComputed; public readonly last_server_timestamp: ko.Observable; @@ -295,6 +296,8 @@ export class Conversation { this.call = ko.observable(null); + this.readOnlyState = ko.observable(null); + // Conversation states for view this.notificationState = ko.pureComputed(() => { if (!this.selfUser()) { @@ -567,6 +570,7 @@ export class Conversation { private _initSubscriptions() { [ this.archivedState, + this.readOnlyState, this.archivedTimestamp, this.cleared_timestamp, this.messageTimer, @@ -1032,6 +1036,7 @@ export class Conversation { access: this.accessModes, access_role: this.accessRole, archived_state: this.archivedState(), + readonly_state: this.readOnlyState(), archived_timestamp: this.archivedTimestamp(), cipher_suite: this.cipherSuite, cleared_timestamp: this.cleared_timestamp(), diff --git a/src/script/page/AppMain.tsx b/src/script/page/AppMain.tsx index 6d43938a2733..c973a3472540 100644 --- a/src/script/page/AppMain.tsx +++ b/src/script/page/AppMain.tsx @@ -226,6 +226,7 @@ const AppMain: FC = ({ selfUser={selfUser} isRightSidebarOpen={!!currentState} openRightSidebar={toggleRightSidebar} + onRefresh={app.refresh} /> )} diff --git a/src/script/page/MainContent/MainContent.test.tsx b/src/script/page/MainContent/MainContent.test.tsx index 265e968ed7f2..5b26990f64ed 100644 --- a/src/script/page/MainContent/MainContent.test.tsx +++ b/src/script/page/MainContent/MainContent.test.tsx @@ -44,6 +44,7 @@ describe('Preferences', () => { const defaultParams = { openRightSidebar: jest.fn(), selfUser: new User('selfUser'), + onRefresh: jest.fn(), }; it('renders the right component according to view state', () => { diff --git a/src/script/page/MainContent/MainContent.tsx b/src/script/page/MainContent/MainContent.tsx index 2467b009f659..016b8f0e81a4 100644 --- a/src/script/page/MainContent/MainContent.tsx +++ b/src/script/page/MainContent/MainContent.tsx @@ -63,6 +63,7 @@ interface MainContentProps { isRightSidebarOpen?: boolean; selfUser: User; conversationState?: ConversationState; + onRefresh: () => void; } const MainContent: FC = ({ @@ -70,6 +71,7 @@ const MainContent: FC = ({ isRightSidebarOpen = false, selfUser, conversationState = container.resolve(ConversationState), + onRefresh, }) => { const [uploadedFile, setUploadedFile] = useState(null); const mainViewModel = useContext(RootContext); @@ -239,6 +241,7 @@ const MainContent: FC = ({ userState={userState} isRightSidebarOpen={isRightSidebarOpen} openRightSidebar={openRightSidebar} + onRefresh={onRefresh} /> )} diff --git a/src/script/storage/record/ConversationRecord.ts b/src/script/storage/record/ConversationRecord.ts index a18ebd2ff335..573fa93f8021 100644 --- a/src/script/storage/record/ConversationRecord.ts +++ b/src/script/storage/record/ConversationRecord.ts @@ -30,6 +30,8 @@ import type {QualifiedId} from '@wireapp/api-client/lib/user/'; import {LegalHoldStatus} from '@wireapp/protocol-messaging'; +import {CONVERSATION_READONLY_STATE} from 'src/script/conversation/ConversationRepository'; + import {ConversationStatus} from '../../conversation/ConversationStatus'; import {ConversationVerificationState} from '../../conversation/ConversationVerificationState'; @@ -37,6 +39,7 @@ export interface ConversationRecord { access_role: CONVERSATION_LEGACY_ACCESS_ROLE | CONVERSATION_ACCESS_ROLE[]; access: CONVERSATION_ACCESS[]; archived_state: boolean; + readonly_state: CONVERSATION_READONLY_STATE | null; archived_timestamp: number; cipher_suite: number; cleared_timestamp: number; diff --git a/src/style/content/conversation.less b/src/style/content/conversation.less index c0fafafb3c5c..9fb91e8d363d 100644 --- a/src/style/content/conversation.less +++ b/src/style/content/conversation.less @@ -59,3 +59,41 @@ opacity: 0; } } + +.readonly-message-container { + max-width: calc(800 - var(--conversation-message-timestamp-width)); + margin-bottom: 16px; +} + +.readonly-message-header { + position: relative; + display: flex; + max-width: 800px; + padding-top: 6px; + line-height: 24px; +} + +.readonly-message-header-icon { + display: flex; + width: 56px; + max-height: 24px; + align-items: center; + align-self: top; + justify-content: center; + color: var(--background); +} + +.readonly-message-header-icon--svg { + line-height: 0; +} + +.readonly-message-header-label { + display: flex; + min-width: 0; + flex: 1; + flex-wrap: wrap; + align-items: center; + font-size: 0.75rem; + font-weight: var(--font-weight-regular); + white-space: normal; +} diff --git a/test/helper/ConversationGenerator.ts b/test/helper/ConversationGenerator.ts index eed312b1a01c..a51e435be6ee 100644 --- a/test/helper/ConversationGenerator.ts +++ b/test/helper/ConversationGenerator.ts @@ -60,6 +60,7 @@ export function generateAPIConversation({ status: ConversationStatus.CURRENT_MEMBER, is_guest: false, archived_state: false, + readonly_state: null, archived_timestamp: 0, last_event_timestamp: 0, last_read_timestamp: 0,