[IMPROVE] Add OTR Room States (#24565)

* WIP: OTR Room States

* lint

* remove logs

* new OTR components, remove modals

* updating stories

* convert js files to ts

* correct a type

* add missing translation

* fix review

* chore: remove OTRModal

* fix: review

Co-authored-by: dougfabris <devfabris@gmail.com>
pull/24515/head^2
Yash Rajpal 4 years ago committed by GitHub
parent d1318e272f
commit 9e712ea8cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      apps/meteor/app/otr/client/OtrRoomState.ts
  2. 13
      apps/meteor/app/otr/client/rocketchat.otr.js
  3. 60
      apps/meteor/app/otr/client/rocketchat.otr.room.js
  4. 53
      apps/meteor/client/views/room/contextualBar/OTR/OTR.js
  5. 20
      apps/meteor/client/views/room/contextualBar/OTR/OTR.stories.tsx
  6. 89
      apps/meteor/client/views/room/contextualBar/OTR/OTR.tsx
  7. 30
      apps/meteor/client/views/room/contextualBar/OTR/OTRModal.js
  8. 67
      apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.js
  9. 60
      apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.tsx
  10. 24
      apps/meteor/client/views/room/contextualBar/OTR/components/OTREstablished.tsx
  11. 28
      apps/meteor/client/views/room/contextualBar/OTR/components/OTRStates.tsx
  12. 7
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json

@ -0,0 +1,9 @@
export enum OtrRoomState {
DISABLED = 'DISABLED',
NOT_STARTED = 'NOT_STARTED',
ESTABLISHING = 'ESTABLISHING',
ESTABLISHED = 'ESTABLISHED',
ERROR = 'ERROR',
TIMEOUT = 'TIMEOUT',
DECLINED = 'DECLINED',
}

@ -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);

@ -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("<i class='otr-icon icon-key'></i>");
}
@ -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: {

@ -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 (
<>
<VerticalBar.Header>
<VerticalBar.Icon name='stopwatch' />
<VerticalBar.Text>{t('OTR')}</VerticalBar.Text>
{onClickClose && <VerticalBar.Close onClick={onClickClose} />}
</VerticalBar.Header>
<VerticalBar.ScrollableContent p='x24'>
<Box fontScale='h4'>{t('Off_the_record_conversation')}</Box>
{!isEstablishing && !isEstablished && isOnline && (
<Button onClick={onClickStart} primary>
{t('Start_OTR')}
</Button>
)}
{isEstablishing && !isEstablished && isOnline && (
<>
{' '}
<Box fontScale='p2'>{t('Please_wait_while_OTR_is_being_established')}</Box> <Throbber inheritColor />{' '}
</>
)}
{isEstablished && isOnline && (
<ButtonGroup stretch>
{onClickRefresh && (
<Button width='50%' onClick={onClickRefresh}>
{t('Refresh_keys')}
</Button>
)}
{onClickEnd && (
<Button width='50%' danger onClick={onClickEnd}>
{t('End_OTR')}
</Button>
)}
</ButtonGroup>
)}
{!isOnline && <Box fontScale='p2m'>{t('OTR_is_only_available_when_both_users_are_online')}</Box>}
</VerticalBar.ScrollableContent>
</>
);
};
export default OTR;

@ -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<typeof OTR> = (args) => <OTR {...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',
};

@ -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<HTMLOrSVGElement>;
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 (
<Button onClick={onClickStart} primary>
{t('Start_OTR')}
</Button>
);
case OtrRoomState.ESTABLISHING:
return (
<Box>
<Box fontScale='p2'>{t('Please_wait_while_OTR_is_being_established')}</Box>
<Box mb='x16'>
<Throbber />
</Box>
</Box>
);
case OtrRoomState.ESTABLISHED:
return <OTREstablished onClickEnd={onClickEnd} onClickRefresh={onClickRefresh} />;
case OtrRoomState.DECLINED:
return (
<OTRStates
title={t('OTR_Chat_Declined_Title')}
description={t('OTR_Chat_Declined_Description', peerUsername || '')}
icon='cross'
onClickStart={onClickStart}
/>
);
case OtrRoomState.TIMEOUT:
return (
<OTRStates
title={t('OTR_Chat_Timeout_Title')}
description={t('OTR_Chat_Timeout_Description', peerUsername || '')}
icon='clock'
onClickStart={onClickStart}
/>
);
default:
return (
<OTRStates
title={t('OTR_Chat_Error_Title')}
description={t('OTR_Chat_Error_Description')}
icon='warning'
onClickStart={onClickStart}
/>
);
}
};
return (
<>
<VerticalBar.Header>
<VerticalBar.Icon name='stopwatch' />
<VerticalBar.Text>{t('OTR')}</VerticalBar.Text>
{onClickClose && <VerticalBar.Close onClick={onClickClose} />}
</VerticalBar.Header>
<VerticalBar.ScrollableContent p='x24'>
<Box fontScale='h4'>{t('Off_the_record_conversation')}</Box>
{isOnline ? renderOTRState() : <Box fontScale='p2m'>{t('OTR_is_only_available_when_both_users_are_online')}</Box>}
</VerticalBar.ScrollableContent>
</>
);
};
export default OTR;

@ -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 (
<Modal {...props}>
<Modal.Header>
<Modal.Title>{t('Timeout')}</Modal.Title>
<Modal.Close onClick={onCancel} />
</Modal.Header>
<Modal.Content>
<Box textAlign='center' color='danger-500'>
<Icon size='x82' name='circle-cross' />
</Box>
</Modal.Content>
<Modal.Footer>
<ButtonGroup align='end'>
<Button primary onClick={onConfirm}>
{confirmLabel}
</Button>
</ButtonGroup>
</Modal.Footer>
</Modal>
);
};
export default OTRModal;

@ -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(<OTRModal onConfirm={closeModal} onCancel={closeModal} />);
}, 10000);
return () => clearTimeout(timeout);
}, [closeModal, isEstablished, isEstablishing, setModal, otr]);
return (
<OTR
isOnline={isOnline}
isEstablishing={isEstablishing}
isEstablished={isEstablished}
onClickClose={onClickClose}
onClickStart={handleStart}
onClickEnd={handleEnd}
onClickRefresh={handleReset}
/>
);
};
export default OTRWithData;

@ -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 (
<OTR
isOnline={isOnline}
onClickClose={closeTabBar}
onClickStart={handleStart}
onClickEnd={handleEnd}
onClickRefresh={handleReset}
otrState={otrState}
peerUsername={peerUsername}
/>
);
};
export default OTRWithData;

@ -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 (
<ButtonGroup stretch>
<Button onClick={onClickRefresh}>{t('Refresh_keys')}</Button>
<Button danger onClick={onClickEnd}>
{t('End_OTR')}
</Button>
</ButtonGroup>
);
};
export default OTREstablished;

@ -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<typeof Icon>['name'];
onClickStart: () => void;
};
const OTRStates = ({ title, description, icon, onClickStart }: OTRStatesProps): ReactElement => {
const t = useTranslation();
return (
<States>
<StatesIcon name={icon} />
<StatesTitle>{title}</StatesTitle>
<StatesSubtitle>{description}</StatesSubtitle>
<StatesActions>
<StatesAction onClick={onClickStart}>{t('New_OTR_Chat')}</StatesAction>
</StatesActions>
</States>
);
};
export default OTRStates;

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

Loading…
Cancel
Save