diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index 78cfa4da3e8..34441554b7f 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -1166,7 +1166,7 @@ "ongoingGroupAudioCall": "Ongoing conference call with {{conversationName}}.", "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 [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.", + "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.", "participantDevicesDetailHeadline": "Verify that this matches the fingerprint shown on [bold]{{user}}’s device[/bold].", "participantDevicesDetailHowTo": "How do I do that?", "participantDevicesDetailResetSession": "Reset session", @@ -1377,7 +1377,7 @@ "searchTrySearch": "Find people by\nname or username", "searchTrySearchFederation": "Find people in Wire by name or\n@username\n\nFind people from another domain\nby @username@domainname", "searchTrySearchLearnMore": "Learn more", - "selfNotSupportMLSMsgPart1": "You can't communicate with [bold]{{selfUserName}}[/bold] anymore, as your device doesn't support the suitable protocol.", + "selfNotSupportMLSMsgPart1": "You can't communicate with {{selfUserName}} anymore, as your device doesn't support the suitable protocol.", "selfNotSupportMLSMsgPart2": "to call, and send messages and files again.", "selfProfileImageAlt": "Your profile picture", "servicesOptionsTitle": "Services", diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index c1c3d7bf311..312cf2baedb 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -35,7 +35,6 @@ 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'; @@ -45,7 +44,7 @@ import {safeMailOpen, safeWindowOpen} from 'Util/SanitizationUtil'; import {formatBytes, incomingCssClass, removeAnimationsClass} from 'Util/util'; import {useReadReceiptSender} from './hooks/useReadReceipt'; -import {ReadOnlyConversationMessage} from './ReadOnlyConversationMessage'; +import {ReadOnlyConversationMessage} from './ReadOnlyConversationMessage/ReadOnlyConversationMessage'; import {checkFileSharingPermission} from './utils/checkFileSharingPermission'; import {ConversationState} from '../../conversation/ConversationState'; @@ -103,19 +102,11 @@ export const Conversation = ({ 'isFileSharingSendingEnabled', ]); - const { - is1to1, - isRequest, - readOnlyState, - display_name: displayName, - } = useKoSubscribableChildren(activeConversation!, ['is1to1', 'isRequest', 'display_name', 'readOnlyState']); - - 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 {is1to1, isRequest, isReadOnlyConversation} = useKoSubscribableChildren(activeConversation!, [ + 'is1to1', + 'isRequest', + 'isReadOnlyConversation', + ]); const inTeam = teamState.isInTeam(selfUser); @@ -488,7 +479,7 @@ export const Conversation = ({ callActions={mainViewModel.calling.callActions} openRightSidebar={openRightSidebar} isRightSidebarOpen={isRightSidebarOpen} - isReadOnlyConversation={showReadOnlyConversationMessage} + isReadOnlyConversation={isReadOnlyConversation} /> {activeCalls.map(call => { @@ -539,12 +530,8 @@ export const Conversation = ({ /> {isConversationLoaded && - (showReadOnlyConversationMessage ? ( - + (isReadOnlyConversation ? ( + ) : ( 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/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.test.tsx b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.test.tsx new file mode 100644 index 00000000000..45dd4b990f6 --- /dev/null +++ b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.test.tsx @@ -0,0 +1,94 @@ +/* + * Wire + * Copyright (C) 2024 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 {render} from '@testing-library/react'; +import {ConnectionStatus} from '@wireapp/api-client/lib/connection'; +import {CONVERSATION_TYPE} from '@wireapp/api-client/lib/conversation'; + +import {withTheme} from 'src/script/auth/util/test/TestUtil'; +import {ConnectionEntity} from 'src/script/connection/ConnectionEntity'; +import {CONVERSATION_READONLY_STATE} from 'src/script/conversation/ConversationRepository'; +import {Conversation} from 'src/script/entity/Conversation'; +import {User} from 'src/script/entity/User'; + +import {ReadOnlyConversationMessage} from './ReadOnlyConversationMessage'; + +const generateConversation = ( + readOnlyState: CONVERSATION_READONLY_STATE | null = null, + is1To1WithBlockedUser = false, + userName = 'John Doe', +) => { + const conversation = new Conversation(); + conversation.type(CONVERSATION_TYPE.ONE_TO_ONE); + conversation.readOnlyState(readOnlyState); + + const connection = new ConnectionEntity(); + + if (is1To1WithBlockedUser) { + connection.status(ConnectionStatus.BLOCKED); + } + + const user = new User('user-id', 'user-domain'); + user.name(userName); + conversation.participating_user_ets([user]); + conversation.participating_user_ids([user.qualifiedId]); + + user.connection(connection); + connection.userId = user.qualifiedId; + + return conversation; +}; + +describe('ReadOnlyConversationMessage', () => { + it('renders mls is not supported by the other user', () => { + const conversation = generateConversation( + CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_OTHER_UNSUPPORTED_MLS, + false, + ); + + const {getByText} = render( + withTheme( {}} />), + ); + + expect(getByText('otherUserNotSupportMLSMsg')).toBeDefined(); + }); + + it('renders mls is not supported by the self user', () => { + const conversation = generateConversation( + CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_SELF_UNSUPPORTED_MLS, + false, + ); + + const reloadAppMock = jest.fn(); + + const {getByText} = render( + withTheme(), + ); + + const reloadButton = getByText('downloadLatestMLS'); + + expect(getByText('selfNotSupportMLSMsgPart1')).toBeDefined(); + expect(reloadButton).toBeDefined(); + expect(getByText('selfNotSupportMLSMsgPart2')).toBeDefined(); + + reloadButton.click(); + + expect(reloadAppMock).toHaveBeenCalled(); + }); +}); diff --git a/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx new file mode 100644 index 00000000000..acf67ecec51 --- /dev/null +++ b/src/script/components/Conversation/ReadOnlyConversationMessage/ReadOnlyConversationMessage.tsx @@ -0,0 +1,109 @@ +/* + * 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, ReactNode} 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 {Conversation} from 'src/script/entity/Conversation'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; +import {t} from 'Util/LocalizerUtil'; +import {replaceReactComponents} from 'Util/LocalizerUtil/ReactLocalizerUtil'; + +interface ReadOnlyConversationMessageProps { + reloadApp: () => void; + conversation: Conversation; +} + +export const ReadOnlyConversationMessage: FC = ({conversation, reloadApp}) => { + const { + readOnlyState, + is1to1, + participating_user_ets: participatingUserEts, + } = useKoSubscribableChildren(conversation, ['readOnlyState', 'is1to1', 'participating_user_ets']); + + const user = (is1to1 && participatingUserEts[0]) || null; + + if (!user) { + // This should never happen for 1:1 conversations + return null; + } + + if (readOnlyState) { + switch (readOnlyState) { + case CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_OTHER_UNSUPPORTED_MLS: + return ( + + + {replaceReactComponents(t('otherUserNotSupportMLSMsg'), [ + { + exactMatch: '{{participantName}}', + render: () => {user.name()}, + }, + ])} + + + ); + case CONVERSATION_READONLY_STATE.READONLY_ONE_TO_ONE_SELF_UNSUPPORTED_MLS: + return ( + + + {replaceReactComponents(t('selfNotSupportMLSMsgPart1'), [ + { + exactMatch: '{{selfUserName}}', + render: () => {user.name()}, + }, + ])} + + <> + {' '} + + {t('downloadLatestMLS')} + {' '} + {t('selfNotSupportMLSMsgPart2')} + + + ); + } + } + + return null; +}; + +const ReadOnlyConversationMessageBase = ({children}: {children: ReactNode}) => { + return ( +
+
+
+ +
+
+

+ {children} +

+
+ ); +}; diff --git a/src/script/entity/Conversation.ts b/src/script/entity/Conversation.ts index 692a090eb81..79ec770ec87 100644 --- a/src/script/entity/Conversation.ts +++ b/src/script/entity/Conversation.ts @@ -89,6 +89,7 @@ export class Conversation { public readonly readOnlyState: ko.Observable; private readonly incomingMessages: ko.ObservableArray; public readonly isProteusTeam1to1: ko.PureComputed; + public readonly isReadOnlyConversation: ko.PureComputed; public readonly last_server_timestamp: ko.Observable; private readonly logger: Logger; public readonly mutedState: ko.Observable; @@ -242,6 +243,9 @@ export class Conversation { otherMembersLength: this.participating_user_ids().length, }), ); + + this.isReadOnlyConversation = ko.pureComputed(() => this.readOnlyState() !== null); + this.isGroup = ko.pureComputed(() => { const isGroupConversation = this.type() === CONVERSATION_TYPE.REGULAR; return isGroupConversation && !this.isProteusTeam1to1(); diff --git a/src/script/util/LocalizerUtil/ReactLocalizerUtil.test.tsx b/src/script/util/LocalizerUtil/ReactLocalizerUtil.test.tsx index f8d5ed787da..957fc1a5764 100644 --- a/src/script/util/LocalizerUtil/ReactLocalizerUtil.test.tsx +++ b/src/script/util/LocalizerUtil/ReactLocalizerUtil.test.tsx @@ -17,6 +17,8 @@ * */ +import {render} from '@testing-library/react'; + import {replaceReactComponents} from './ReactLocalizerUtil'; describe('replaceReactComponents', () => { @@ -65,4 +67,98 @@ describe('replaceReactComponents', () => { expect(result).toHaveLength(4); }); + + it('replaces literal strings with a component', () => { + const username = 'Patryk'; + const result = replaceReactComponents('Hello {{username}}!', [ + { + exactMatch: '{{username}}', + render: () => {username}, + }, + ]); + const {getByText} = render(
{result}
); + + expect(getByText(username)).toBeTruthy(); + }); + + it('replaces literal strings with a string', () => { + const username = 'Przemek'; + const result = replaceReactComponents('Hello {{username}}!', [ + { + exactMatch: '{{username}}', + render: () => username, + }, + ]); + + const {getByTestId} = render(

{result}

); + + expect(result).toHaveLength(3); + expect(getByTestId('parent').textContent).toEqual('Hello Przemek!'); + }); + + it('replaces multiple literal strings', () => { + const username1 = 'John'; + const username2 = 'Jerry'; + const result = replaceReactComponents(`Hello {{username1}} and {{username2}}, my name is also {{username1}}!`, [ + { + exactMatch: '{{username1}}', + render: () => {username1}, + }, + { + exactMatch: '{{username2}}', + render: () => {username2}, + }, + ]); + + const {getByTestId} = render(

{result}

); + + expect(result).toHaveLength(7); + expect(getByTestId('parent').textContent).toEqual('Hello John and Jerry, my name is also John!'); + }); + + it('replaces components and literal strings at the same time', () => { + const username1 = 'Tom'; + const username2 = 'Tim'; + const result = replaceReactComponents(`Hello [bold]${username1}[/bold] and {{username2}}!`, [ + { + start: '[bold]', + end: '[/bold]', + render: text => {text}, + }, + { + exactMatch: '{{username2}}', + render: () => {username2}, + }, + ]); + + const {getByTestId} = render(

{result}

); + + expect(result).toHaveLength(5); + expect(getByTestId('parent').textContent).toEqual('Hello Tom and Tim!'); + }); + + it('replaces literal string inside of a component', () => { + const username = 'Jake'; + const username2 = 'Marco'; + const result = replaceReactComponents(`Hello [bold]{{username}}[/bold], [bold]Paul[/bold] and {{username2}}!`, [ + { + start: '[bold]', + end: '[/bold]', + render: text => {text}, + }, + { + exactMatch: '{{username}}', + render: () => {username}, + }, + { + exactMatch: '{{username2}}', + render: () => {username2}, + }, + ]); + + const {getByTestId} = render(

{result}

); + + expect(result).toHaveLength(7); + expect(getByTestId('parent').textContent).toEqual('Hello Jake, Paul and Marco!'); + }); }); diff --git a/src/script/util/LocalizerUtil/ReactLocalizerUtil.ts b/src/script/util/LocalizerUtil/ReactLocalizerUtil.ts deleted file mode 100644 index 1753fa34002..00000000000 --- a/src/script/util/LocalizerUtil/ReactLocalizerUtil.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 React from 'react'; - -interface Replacement { - start: string; - end: string; - render: (text: string) => React.ReactNode; -} - -function sanitizeRegexp(text: string) { - return text.replaceAll('[', '\\[').replaceAll(']', '\\]'); -} - -/** - * Will replace all occurences of `replacements` by a React component returned by `render`. - */ -export function replaceReactComponents(html: string, replacements: Replacement[]): React.ReactNode[] { - if (!replacements.length) { - return [html]; - } - const splitRegexp = new RegExp( - `(${replacements - .map(replacement => `${sanitizeRegexp(replacement.start)}.+?${sanitizeRegexp(replacement.end)}`) - .join('|')})`, - 'g', - ); - return html - .split(splitRegexp) - .map(node => { - const match = replacements.find( - replacement => node.startsWith(replacement.start) && node.endsWith(replacement.end), - ); - - if (match) { - const text = node.substring(match.start.length, node.length - match.end.length); - return match.render(text); - } - return node; - }) - .filter(Boolean); -} diff --git a/src/script/util/LocalizerUtil/ReactLocalizerUtil.tsx b/src/script/util/LocalizerUtil/ReactLocalizerUtil.tsx new file mode 100644 index 00000000000..de96c865330 --- /dev/null +++ b/src/script/util/LocalizerUtil/ReactLocalizerUtil.tsx @@ -0,0 +1,118 @@ +/* + * 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 React from 'react'; + +interface ComponentReplacement { + start: string; + end: string; + render: (text: string) => React.ReactNode; +} + +interface StringReplacement { + exactMatch: string; + render: () => React.ReactNode | string; +} + +type Replacement = ComponentReplacement | StringReplacement; + +function sanitizeRegexp(text: string) { + return text.replaceAll('[', '\\[').replaceAll(']', '\\]'); +} + +/** + * Will replace all occurences of `replacements` by a React component returned by `render`. + */ +export function replaceReactComponents(html: string, replacements: Replacement[]): React.ReactNode[] { + const [stringReplacements, componentReplacements] = replacements.reduce( + (acc, replacement) => { + if ('exactMatch' in replacement) { + acc[0].push(replacement); + } else { + acc[1].push(replacement); + } + return acc; + }, + [[], []] as [StringReplacement[], ComponentReplacement[]], + ); + + if (!componentReplacements.length && !stringReplacements.length) { + return [html]; + } + + const componentsSplitRegexpStr = componentReplacements.length + ? `(${componentReplacements + .map(replacement => `${sanitizeRegexp(replacement.start)}.+?${sanitizeRegexp(replacement.end)}`) + .join('|')})` + : null; + + const stringSplitRegexpStr = stringReplacements.length + ? `(${stringReplacements.map(replacement => sanitizeRegexp(replacement.exactMatch)).join('|')})` + : null; + + const regexpStr = [componentsSplitRegexpStr, stringSplitRegexpStr].filter(Boolean).join('|'); + + const splitRegexp = new RegExp(regexpStr, 'g'); + + return html + .split(splitRegexp) + .map(node => { + if (!node) { + return false; + } + const componentsReplacementMatch = componentReplacements.find( + replacement => node.startsWith(replacement.start) && node.endsWith(replacement.end), + ); + + if (componentsReplacementMatch) { + const text = node.substring( + componentsReplacementMatch.start.length, + node.length - componentsReplacementMatch.end.length, + ); + + // There is a special case where we have a string replacement inside a component replacement. + if (stringSplitRegexpStr) { + const regexp = new RegExp(stringSplitRegexpStr, 'g'); + const split = text.split(regexp); + return split + .map(node => { + const stringReplacementMatch = stringReplacements.find(replacement => node === replacement.exactMatch); + if (stringReplacementMatch) { + return stringReplacementMatch.render(); + } + return componentsReplacementMatch.render(node); + }) + .filter(Boolean) + .map((node, index) => {node}); + } + + return componentsReplacementMatch.render(text); + } + + const stringReplacementMatch = stringReplacements.find(replacement => node === replacement.exactMatch); + + if (stringReplacementMatch) { + return stringReplacementMatch.render(); + } + + return node; + }) + .filter(Boolean) + .map((node, index) => {node}); // Make sure we have a different key for each node. +}