diff --git a/apps/meteor/app/otr/client/OtrRoomState.ts b/apps/meteor/app/otr/client/OtrRoomState.ts new file mode 100644 index 00000000000..41233cef9a5 --- /dev/null +++ b/apps/meteor/app/otr/client/OtrRoomState.ts @@ -0,0 +1,9 @@ +export enum OtrRoomState { + DISABLED = 'DISABLED', + NOT_STARTED = 'NOT_STARTED', + ESTABLISHING = 'ESTABLISHING', + ESTABLISHED = 'ESTABLISHED', + ERROR = 'ERROR', + TIMEOUT = 'TIMEOUT', + DECLINED = 'DECLINED', +} diff --git a/apps/meteor/app/otr/client/rocketchat.otr.js b/apps/meteor/app/otr/client/rocketchat.otr.js index c4eee72e92c..f8a660d7fcf 100644 --- a/apps/meteor/app/otr/client/rocketchat.otr.js +++ b/apps/meteor/app/otr/client/rocketchat.otr.js @@ -7,6 +7,7 @@ import { Notifications } from '../../notifications'; import { t } from '../../utils'; import { onClientMessageReceived } from '../../../client/lib/onClientMessageReceived'; import { onClientBeforeSendMessage } from '../../../client/lib/onClientBeforeSendMessage'; +import { OtrRoomState } from './OtrRoomState'; class OTRClass { constructor() { @@ -56,7 +57,11 @@ Meteor.startup(function () { }); onClientBeforeSendMessage.use(function (message) { - if (message.rid && OTR.getInstanceByRoomId(message.rid) && OTR.getInstanceByRoomId(message.rid).established.get()) { + if ( + message.rid && + OTR.getInstanceByRoomId(message.rid) && + OTR.getInstanceByRoomId(message.rid).state.get() === OtrRoomState.ESTABLISHED + ) { return OTR.getInstanceByRoomId(message.rid) .encrypt(message) .then((msg) => { @@ -69,7 +74,11 @@ Meteor.startup(function () { }); onClientMessageReceived.use(function (message) { - if (message.rid && OTR.getInstanceByRoomId(message.rid) && OTR.getInstanceByRoomId(message.rid).established.get()) { + if ( + message.rid && + OTR.getInstanceByRoomId(message.rid) && + OTR.getInstanceByRoomId(message.rid).state.get() === OtrRoomState.ESTABLISHED + ) { if (message.notification) { message.msg = t('Encrypted_message'); return Promise.resolve(message); diff --git a/apps/meteor/app/otr/client/rocketchat.otr.room.js b/apps/meteor/app/otr/client/rocketchat.otr.room.js index 1c943066813..95b57ad70b7 100644 --- a/apps/meteor/app/otr/client/rocketchat.otr.room.js +++ b/apps/meteor/app/otr/client/rocketchat.otr.room.js @@ -15,6 +15,7 @@ import { goToRoomById } from '../../../client/lib/utils/goToRoomById'; import { imperativeModal } from '../../../client/lib/imperativeModal'; import GenericModal from '../../../client/components/GenericModal'; import { dispatchToastMessage } from '../../../client/lib/toast'; +import { OtrRoomState } from './OtrRoomState'; import { otrSystemMessages } from '../lib/constants'; import { APIClient } from '../../utils/client'; @@ -23,8 +24,8 @@ OTR.Room = class { this.userId = userId; this.roomId = roomId; this.peerId = getUidDirectMessage(roomId); - this.established = new ReactiveVar(false); - this.establishing = new ReactiveVar(false); + this.state = new ReactiveVar(OtrRoomState.NOT_STARTED); + this.isFirstOTR = true; this.userOnlineComputation = null; @@ -34,8 +35,17 @@ OTR.Room = class { this.sessionKey = null; } + setState(nextState) { + const currentState = this.state.get(); + if (currentState === nextState) { + return; + } + + this.state.set(nextState); + } + handshake(refresh) { - this.establishing.set(true); + this.setState(OtrRoomState.ESTABLISHING); this.firstPeer = true; this.generateKeyPair().then(() => { Notifications.notifyUser(this.peerId, 'otr', 'handshake', { @@ -63,6 +73,7 @@ OTR.Room = class { deny() { this.reset(); + this.setState(OtrRoomState.DECLINED); Notifications.notifyUser(this.peerId, 'otr', 'deny', { roomId: this.roomId, userId: this.userId, @@ -72,6 +83,7 @@ OTR.Room = class { end() { this.isFirstOTR = true; this.reset(); + this.setState(OtrRoomState.NOT_STARTED); Notifications.notifyUser(this.peerId, 'otr', 'end', { roomId: this.roomId, userId: this.userId, @@ -79,8 +91,6 @@ OTR.Room = class { } reset() { - this.establishing.set(false); - this.established.set(false); this.keyPair = null; this.exportedPublicKey = null; this.sessionKey = null; @@ -95,7 +105,7 @@ OTR.Room = class { this.userOnlineComputation = Tracker.autorun(() => { const $room = $(`#chat-window-${this.roomId}`); const $title = $('.rc-header__title', $room); - if (this.established.get()) { + if (this.state.get() === OtrRoomState.ESTABLISHED) { if ($room.length && $title.length && !$('.otr-icon', $title).length) { $title.prepend(""); } @@ -125,6 +135,7 @@ OTR.Room = class { Meteor.call('deleteOldOTRMessages', this.roomId); }) .catch((e) => { + this.setState(OtrRoomState.ERROR); dispatchToastMessage({ type: 'error', message: e }); }); } @@ -202,6 +213,7 @@ OTR.Room = class { return EJSON.stringify(output); }) .catch(() => { + this.setState(OtrRoomState.ERROR); throw new Meteor.Error('encryption-error', 'Encryption error.'); }); } @@ -247,6 +259,7 @@ OTR.Room = class { }) .catch((e) => { dispatchToastMessage({ type: 'error', message: e }); + this.setState(OtrRoomState.ERROR); return message; }); } @@ -256,14 +269,14 @@ OTR.Room = class { case 'handshake': let timeout = null; const establishConnection = () => { - this.establishing.set(true); + this.setState(OtrRoomState.ESTABLISHING); Meteor.clearTimeout(timeout); this.generateKeyPair().then(() => { this.importPublicKey(data.publicKey).then(() => { this.firstPeer = false; goToRoomById(data.roomId); Meteor.defer(() => { - this.established.set(true); + this.setState(OtrRoomState.ESTABLISHED); this.acknowledge(); if (data.refresh) { Meteor.call('sendSystemMessages', this.roomId, Meteor.user(), otrSystemMessages.USER_KEY_REFRESHED_SUCCESSFULLY); @@ -275,11 +288,11 @@ OTR.Room = class { (async () => { const { username } = await Presence.get(data.userId); - if (data.refresh && this.established.get()) { + if (data.refresh && this.state.get() === OtrRoomState.ESTABLISHED) { this.reset(); establishConnection(); } else { - if (this.established.get()) { + if (this.state.get() === OtrRoomState.ESTABLISHED) { this.reset(); } @@ -293,7 +306,11 @@ OTR.Room = class { }), confirmText: TAPi18n.__('Yes'), cancelText: TAPi18n.__('No'), - onClose: () => imperativeModal.close, + onClose: () => { + Meteor.clearTimeout(timeout); + this.deny(); + imperativeModal.close(); + }, onCancel: () => { Meteor.clearTimeout(timeout); this.deny(); @@ -308,7 +325,7 @@ OTR.Room = class { } timeout = Meteor.setTimeout(() => { - this.establishing.set(false); + this.setState(OtrRoomState.TIMEOUT); imperativeModal.close(); }, 10000); })(); @@ -316,7 +333,7 @@ OTR.Room = class { case 'acknowledge': this.importPublicKey(data.publicKey).then(() => { - this.established.set(true); + this.setState(OtrRoomState.ESTABLISHED); }); if (this.isFirstOTR) { Meteor.call('sendSystemMessages', this.roomId, Meteor.user(), otrSystemMessages.USER_JOINED_OTR); @@ -326,19 +343,9 @@ OTR.Room = class { case 'deny': (async () => { - const { username } = await Presence.get(this.peerId); - if (this.establishing.get()) { + if (this.state.get() === OtrRoomState.ESTABLISHING) { this.reset(); - imperativeModal.open({ - component: GenericModal, - props: { - variant: 'warning', - title: TAPi18n.__('OTR'), - children: TAPi18n.__('Username_denied_the_OTR_session', { username }), - onClose: imperativeModal.close, - onConfirm: imperativeModal.close, - }, - }); + this.setState(OtrRoomState.DECLINED); } })(); break; @@ -347,8 +354,9 @@ OTR.Room = class { (async () => { const { username } = await Presence.get(this.peerId); - if (this.established.get()) { + if (this.state.get() === OtrRoomState.ESTABLISHED) { this.reset(); + this.setState(OtrRoomState.NOT_STARTED); imperativeModal.open({ component: GenericModal, props: { diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTR.js b/apps/meteor/client/views/room/contextualBar/OTR/OTR.js deleted file mode 100644 index fc276aa4e26..00000000000 --- a/apps/meteor/client/views/room/contextualBar/OTR/OTR.js +++ /dev/null @@ -1,53 +0,0 @@ -import { Box, Button, ButtonGroup, Throbber } from '@rocket.chat/fuselage'; -import React from 'react'; - -import VerticalBar from '../../../../components/VerticalBar'; -import { useTranslation } from '../../../../contexts/TranslationContext'; - -const OTR = ({ isEstablishing, isEstablished, isOnline, onClickClose, onClickStart, onClickEnd, onClickRefresh }) => { - const t = useTranslation(); - - return ( - <> - - - {t('OTR')} - {onClickClose && } - - - - {t('Off_the_record_conversation')} - - {!isEstablishing && !isEstablished && isOnline && ( - - )} - {isEstablishing && !isEstablished && isOnline && ( - <> - {' '} - {t('Please_wait_while_OTR_is_being_established')} {' '} - - )} - {isEstablished && isOnline && ( - - {onClickRefresh && ( - - )} - {onClickEnd && ( - - )} - - )} - - {!isOnline && {t('OTR_is_only_available_when_both_users_are_online')}} - - - ); -}; - -export default OTR; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTR.stories.tsx b/apps/meteor/client/views/room/contextualBar/OTR/OTR.stories.tsx index cb6d2a66c85..cde3786fb35 100644 --- a/apps/meteor/client/views/room/contextualBar/OTR/OTR.stories.tsx +++ b/apps/meteor/client/views/room/contextualBar/OTR/OTR.stories.tsx @@ -1,6 +1,7 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; import React from 'react'; +import { OtrRoomState } from '../../../../../app/otr/client/OtrRoomState'; import VerticalBar from '../../../../components/VerticalBar'; import OTR from './OTR'; @@ -19,21 +20,36 @@ const Template: ComponentStory = (args) => ; export const Default = Template.bind({}); Default.args = { isOnline: true, + otrState: OtrRoomState.NOT_STARTED, }; export const Establishing = Template.bind({}); Establishing.args = { isOnline: true, - isEstablishing: true, + otrState: OtrRoomState.ESTABLISHING, }; export const Established = Template.bind({}); Established.args = { isOnline: true, - isEstablished: true, + otrState: OtrRoomState.ESTABLISHED, }; export const Unavailable = Template.bind({}); Unavailable.args = { isOnline: false, }; + +export const Timeout = Template.bind({}); +Timeout.args = { + isOnline: true, + otrState: OtrRoomState.TIMEOUT, + peerUsername: 'testUser', +}; + +export const Declined = Template.bind({}); +Declined.args = { + isOnline: true, + otrState: OtrRoomState.DECLINED, + peerUsername: 'testUser', +}; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTR.tsx b/apps/meteor/client/views/room/contextualBar/OTR/OTR.tsx new file mode 100644 index 00000000000..010397eefcb --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/OTR/OTR.tsx @@ -0,0 +1,89 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Box, Button, Throbber } from '@rocket.chat/fuselage'; +import React, { MouseEventHandler, ReactElement } from 'react'; + +import { OtrRoomState } from '../../../../../app/otr/client/OtrRoomState'; +import VerticalBar from '../../../../components/VerticalBar'; +import { useTranslation } from '../../../../contexts/TranslationContext'; +import OTREstablished from './components/OTREstablished'; +import OTRStates from './components/OTRStates'; + +type OTRProps = { + isOnline: boolean; + onClickClose: MouseEventHandler; + onClickStart: () => void; + onClickEnd: () => void; + onClickRefresh: () => void; + otrState: string; + peerUsername: IUser['username']; +}; + +const OTR = ({ isOnline, onClickClose, onClickStart, onClickEnd, onClickRefresh, otrState, peerUsername }: OTRProps): ReactElement => { + const t = useTranslation(); + + const renderOTRState = (): ReactElement => { + switch (otrState) { + case OtrRoomState.NOT_STARTED: + return ( + + ); + case OtrRoomState.ESTABLISHING: + return ( + + {t('Please_wait_while_OTR_is_being_established')} + + + + + ); + case OtrRoomState.ESTABLISHED: + return ; + case OtrRoomState.DECLINED: + return ( + + ); + case OtrRoomState.TIMEOUT: + return ( + + ); + default: + return ( + + ); + } + }; + + return ( + <> + + + {t('OTR')} + {onClickClose && } + + + + {t('Off_the_record_conversation')} + {isOnline ? renderOTRState() : {t('OTR_is_only_available_when_both_users_are_online')}} + + + ); +}; + +export default OTR; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTRModal.js b/apps/meteor/client/views/room/contextualBar/OTR/OTRModal.js deleted file mode 100644 index 6042326c0f2..00000000000 --- a/apps/meteor/client/views/room/contextualBar/OTR/OTRModal.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Button, Box, ButtonGroup, Icon, Modal } from '@rocket.chat/fuselage'; -import React from 'react'; - -import { useTranslation } from '../../../../contexts/TranslationContext'; - -const OTRModal = ({ onCancel, onConfirm, confirmLabel = 'Ok', ...props }) => { - const t = useTranslation(); - return ( - - - {t('Timeout')} - - - - - - - - - - - - - - ); -}; - -export default OTRModal; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.js b/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.js deleted file mode 100644 index 0cb4bef8d4f..00000000000 --- a/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.js +++ /dev/null @@ -1,67 +0,0 @@ -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { useEffect, useMemo, useCallback } from 'react'; - -import { OTR as ORTInstance } from '../../../../../app/otr/client/rocketchat.otr'; -import { useSetModal } from '../../../../contexts/ModalContext'; -import { usePresence } from '../../../../hooks/usePresence'; -import { useReactiveValue } from '../../../../hooks/useReactiveValue'; -import OTR from './OTR'; -import OTRModal from './OTRModal'; - -const OTRWithData = ({ rid, tabBar }) => { - const onClickClose = useMutableCallback(() => tabBar && tabBar.close()); - - const setModal = useSetModal(); - const closeModal = useMutableCallback(() => setModal()); - const otr = useMemo(() => ORTInstance.getInstanceByRoomId(rid), [rid]); - - const [isEstablished, isEstablishing] = useReactiveValue( - useCallback(() => (otr ? [otr.established.get(), otr.establishing.get()] : [false, false]), [otr]), - ); - - const userStatus = usePresence(otr.peerId)?.status; - - const isOnline = !['offline', 'loading'].includes(userStatus); - - const handleStart = () => { - otr.handshake(); - }; - - const handleEnd = () => otr?.end(); - - const handleReset = () => { - otr.reset(); - otr.handshake(true); - }; - - useEffect(() => { - if (isEstablished) { - return closeModal(); - } - - if (!isEstablishing) { - return; - } - - const timeout = setTimeout(() => { - otr.establishing.set(false); - setModal(); - }, 10000); - - return () => clearTimeout(timeout); - }, [closeModal, isEstablished, isEstablishing, setModal, otr]); - - return ( - - ); -}; - -export default OTRWithData; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.tsx b/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.tsx new file mode 100644 index 00000000000..b19debfeb88 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.tsx @@ -0,0 +1,60 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import React, { useEffect, useMemo, useCallback, ReactElement } from 'react'; + +import { OtrRoomState } from '../../../../../app/otr/client/OtrRoomState'; +import { OTR as ORTInstance } from '../../../../../app/otr/client/rocketchat.otr'; +import { usePresence } from '../../../../hooks/usePresence'; +import { useReactiveValue } from '../../../../hooks/useReactiveValue'; +import { useTabBarClose } from '../../providers/ToolboxProvider'; +import OTR from './OTR'; + +const OTRWithData = ({ rid }: { rid: IRoom['_id'] }): ReactElement => { + const closeTabBar = useTabBarClose(); + const otr = useMemo(() => ORTInstance.getInstanceByRoomId(rid), [rid]); + const otrState = useReactiveValue(useCallback(() => (otr ? otr.state.get() : OtrRoomState.ERROR), [otr])); + const peerUserPresence = usePresence(otr.peerId); + const userStatus = peerUserPresence?.status; + const peerUsername = peerUserPresence?.username; + const isOnline = !['offline', 'loading'].includes(userStatus || ''); + + const handleStart = (): void => { + otr.handshake(); + }; + + const handleEnd = (): void => { + otr?.end(); + }; + + const handleReset = (): void => { + otr.reset(); + otr.handshake(true); + }; + + useEffect(() => { + if (otrState !== OtrRoomState.ESTABLISHING) { + return; + } + + const timeout = setTimeout(() => { + otr.state.set(OtrRoomState.TIMEOUT); + }, 10000); + + return (): void => { + clearTimeout(timeout); + }; + }, [otr, otrState]); + + return ( + + ); +}; + +export default OTRWithData; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/components/OTREstablished.tsx b/apps/meteor/client/views/room/contextualBar/OTR/components/OTREstablished.tsx new file mode 100644 index 00000000000..03dbf68273f --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/OTR/components/OTREstablished.tsx @@ -0,0 +1,24 @@ +import { Button, ButtonGroup } from '@rocket.chat/fuselage'; +import React, { ReactElement } from 'react'; + +import { useTranslation } from '../../../../../contexts/TranslationContext'; + +type OTREstablishedProps = { + onClickRefresh: () => void; + onClickEnd: () => void; +}; + +const OTREstablished = ({ onClickRefresh, onClickEnd }: OTREstablishedProps): ReactElement => { + const t = useTranslation(); + + return ( + + + + + ); +}; + +export default OTREstablished; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/components/OTRStates.tsx b/apps/meteor/client/views/room/contextualBar/OTR/components/OTRStates.tsx new file mode 100644 index 00000000000..28bf858726f --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/OTR/components/OTRStates.tsx @@ -0,0 +1,28 @@ +import { Icon, States, StatesAction, StatesActions, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; +import React, { ReactElement, ComponentProps } from 'react'; + +import { useTranslation } from '../../../../../contexts/TranslationContext'; + +type OTRStatesProps = { + title: string; + description: string; + icon: ComponentProps['name']; + onClickStart: () => void; +}; + +const OTRStates = ({ title, description, icon, onClickStart }: OTRStatesProps): ReactElement => { + const t = useTranslation(); + + return ( + + + {title} + {description} + + {t('New_OTR_Chat')} + + + ); +}; + +export default OTRStates; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 74b8c56fd3e..1a255d598ca 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -3149,6 +3149,7 @@ "New_logs": "New logs", "New_Message_Notification": "New Message Notification", "New_messages": "New messages", + "New_OTR_Chat": "New OTR Chat", "New_password": "New Password", "New_Password_Placeholder": "Please enter new password...", "New_Priority": "New Priority", @@ -3343,6 +3344,12 @@ "others": "others", "Others": "Others", "OTR": "OTR", + "OTR_Chat_Declined_Title": "OTR Chat invite Declined", + "OTR_Chat_Declined_Description": "%s declined OTR chat invite. For privacy protection local cache was deleted, including all related system messages.", + "OTR_Chat_Error_Title":"Chat ended due to failed key refresh", + "OTR_Chat_Error_Description":"For privacy protection local cache was deleted, including all related system messages.", + "OTR_Chat_Timeout_Title": "OTR chat invite expired", + "OTR_Chat_Timeout_Description": "%s failed to accept OTR chat invite in time. For privacy protection local cache was deleted, including all related system messages.", "OTR_Enable_Description": "Enable option to use off-the-record (OTR) messages in direct messages between 2 users. OTR messages are not recorded on the server and exchanged directly and encrypted between the 2 users.", "OTR_message": "OTR Message", "OTR_is_only_available_when_both_users_are_online": "OTR is only available when both users are online",