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",