diff --git a/lang/main.json b/lang/main.json index 300040a145..ded8cec18e 100644 --- a/lang/main.json +++ b/lang/main.json @@ -147,6 +147,7 @@ "bridgeCount": "Server count: ", "codecs": "Codecs (A/V): ", "connectedTo": "Connected to:", + "e2eeVerified": "E2EE verified:", "framerate": "Frame rate:", "less": "Show less", "localaddress": "Local address:", @@ -408,6 +409,10 @@ "user": "User", "userIdentifier": "User identifier", "userPassword": "User password", + "verifyParticipantConfirm": "They match", + "verifyParticipantDismiss": "They do not match", + "verifyParticipantQuestion": "EXPERIMENTAL: Ask participant {{participantName}} if they see the same content, in the same order.", + "verifyParticipantTitle": "User verification", "videoLink": "Video link", "viewUpgradeOptions": "View upgrade options", "viewUpgradeOptionsContent": "To get unlimited access to premium features like recording, transcriptions, RTMP Streaming & more, you'll need to upgrade your plan.", @@ -1297,6 +1302,7 @@ "show": "Show on stage", "showSelfView": "Show self view", "unpinFromStage": "Unpin", + "verify": "Verify participant", "videoMuted": "Camera disabled", "videomute": "Participant has stopped the camera" }, diff --git a/react/features/base/conference/reducer.ts b/react/features/base/conference/reducer.ts index 5fa9c24b83..e5ac735872 100644 --- a/react/features/base/conference/reducer.ts +++ b/react/features/base/conference/reducer.ts @@ -59,6 +59,7 @@ export interface IJitsiConference { grantOwner: Function; isAVModerationSupported: Function; isCallstatsEnabled: Function; + isE2EEEnabled: Function; isEndConferenceSupported: Function; isLobbySupported: Function; isSIPCallingSupported: Function; @@ -89,6 +90,7 @@ export interface IJitsiConference { setReceiverConstraints: Function; setSenderVideoConstraint: Function; setSubject: Function; + startVerification: Function; } export interface IConferenceState { diff --git a/react/features/base/dialog/middleware.web.ts b/react/features/base/dialog/middleware.web.ts index 50e9029eb3..13b37fdab1 100644 --- a/react/features/base/dialog/middleware.web.ts +++ b/react/features/base/dialog/middleware.web.ts @@ -4,6 +4,7 @@ import WaitForOwnerDialog from '../../authentication/components/web/WaitForOwner import ChatPrivacyDialog from '../../chat/components/web/ChatPrivacyDialog'; import DesktopPicker from '../../desktop-picker/components/DesktopPicker'; import DisplayNamePrompt from '../../display-name/components/web/DisplayNamePrompt'; +import ParticipantVerificationDialog from '../../e2ee/components/ParticipantVerificationDialog'; import EmbedMeetingDialog from '../../embed-meeting/components/EmbedMeetingDialog'; // @ts-ignore import FeedbackDialog from '../../feedback/components/FeedbackDialog.web'; @@ -49,7 +50,7 @@ const NEW_DIALOG_LIST = [ KeyboardShortcutsDialog, ChatPrivacyDialog, DisplayNam SharedVideoDialog, SpeakerStats, LanguageSelectorDialog, MuteEveryoneDialog, MuteEveryonesVideoDialog, GrantModeratorDialog, KickRemoteParticipantDialog, MuteRemoteParticipantsVideoDialog, VideoQualityDialog, VirtualBackgroundDialog, LoginDialog, WaitForOwnerDialog, DesktopPicker, RemoteControlAuthorizationDialog, - LogoutDialog, SalesforceLinkDialog ]; + LogoutDialog, SalesforceLinkDialog, ParticipantVerificationDialog ]; // This function is necessary while the transition from @atlaskit dialog to our component is ongoing. const isNewDialog = (component: any) => NEW_DIALOG_LIST.some(comp => comp === component); diff --git a/react/features/base/participants/types.ts b/react/features/base/participants/types.ts index 7134c6ec50..58cdd4b402 100644 --- a/react/features/base/participants/types.ts +++ b/react/features/base/participants/types.ts @@ -13,6 +13,8 @@ export interface IParticipant { dominantSpeaker?: boolean; e2eeEnabled?: boolean; e2eeSupported?: boolean; + e2eeVerificationAvailable?: boolean; + e2eeVerified?: boolean; email?: string; fakeParticipant?: FakeParticipant; features?: { diff --git a/react/features/connection-indicator/components/web/ConnectionIndicatorContent.js b/react/features/connection-indicator/components/web/ConnectionIndicatorContent.js index 2c3250b170..964deeb8a6 100644 --- a/react/features/connection-indicator/components/web/ConnectionIndicatorContent.js +++ b/react/features/connection-indicator/components/web/ConnectionIndicatorContent.js @@ -189,6 +189,7 @@ class ConnectionIndicatorContent extends AbstractConnectionIndicator { ); } + /** + * Creates a a table row as a ReactElement for displaying e2ee verication status, if present. + * + * @private + * @returns {ReactElement} + */ + _renderE2EEVerified() { + const { e2eeVerified, t } = this.props; + + if (e2eeVerified === undefined) { + return; + } + + const status = e2eeVerified ? '\u{2705}' : '\u{274C}'; + + return ( + + + { t('connectionindicator.e2eeVerified') } + + { status } + + ); + } + /** * Creates a table row as a ReactElement for displaying a summary message @@ -726,6 +756,7 @@ class ConnectionStatsTable extends Component { { this._renderResolution() } { this._renderFrameRate() } { this._renderCodecs() } + { this._renderE2EEVerified() } ); diff --git a/react/features/e2ee/actionTypes.ts b/react/features/e2ee/actionTypes.ts index a5b12dd929..d7498102e6 100644 --- a/react/features/e2ee/actionTypes.ts +++ b/react/features/e2ee/actionTypes.ts @@ -43,3 +43,7 @@ export const SET_MAX_MODE = 'SET_MAX_MODE'; * } */ export const SET_MEDIA_ENCRYPTION_KEY = 'SET_MEDIA_ENCRYPTION_KEY'; + +export const START_VERIFICATION = 'START_VERIFICATION'; + +export const PARTICIPANT_VERIFIED = 'PARTICIPANT_VERIFIED'; diff --git a/react/features/e2ee/actions.ts b/react/features/e2ee/actions.ts index 0548c6a014..ac30dea367 100644 --- a/react/features/e2ee/actions.ts +++ b/react/features/e2ee/actions.ts @@ -1,8 +1,10 @@ import { + PARTICIPANT_VERIFIED, SET_EVERYONE_ENABLED_E2EE, SET_EVERYONE_SUPPORT_E2EE, SET_MAX_MODE, SET_MEDIA_ENCRYPTION_KEY, + START_VERIFICATION, TOGGLE_E2EE } from './actionTypes'; /** @@ -80,3 +82,38 @@ export function setMediaEncryptionKey(keyInfo: Object) { keyInfo }; } + +/** + * Dispatches an action to start participant e2ee verficiation process. + * + * @param {string} pId - The participant id. + * @returns {{ + * type: START_VERIFICATION, + * pId: string + * }} + */ +export function startVerification(pId: string) { + return { + type: START_VERIFICATION, + pId + }; +} + +/** + * Dispatches an action to set participant e2ee verification status. + * + * @param {string} pId - The participant id. + * @param {boolean} isVerified - The verifcation status. + * @returns {{ + * type: PARTICIPANT_VERIFIED, + * pId: string, + * isVerified: boolean + * }} + */ +export function participantVerified(pId: string, isVerified: boolean) { + return { + type: PARTICIPANT_VERIFIED, + pId, + isVerified + }; +} diff --git a/react/features/e2ee/components/ParticipantVerificationDialog.tsx b/react/features/e2ee/components/ParticipantVerificationDialog.tsx new file mode 100644 index 0000000000..dfad1d35dd --- /dev/null +++ b/react/features/e2ee/components/ParticipantVerificationDialog.tsx @@ -0,0 +1,166 @@ +import { withStyles } from '@mui/styles'; +import React, { Component } from 'react'; +import { WithTranslation } from 'react-i18next'; + +import { IReduxState, IStore } from '../../app/types'; +import { translate } from '../../base/i18n/functions'; +import { getParticipantById } from '../../base/participants/functions'; +import { connect } from '../../base/redux/functions'; +import Dialog from '../../base/ui/components/web/Dialog'; +import { participantVerified } from '../actions'; +import { ISas } from '../reducer'; + +interface IProps extends WithTranslation { + classes: any; + decimal: string; + dispatch: IStore['dispatch']; + emoji: string; + pId: string; + participantName: string; + sas: ISas; +} + +/** + * Creates the styles for the component. + * + * @param {Object} theme - The current UI theme. + * + * @returns {Object} + */ +const styles = () => { + return { + container: { + display: 'flex', + flexDirection: 'column', + margin: '16px' + }, + row: { + alignSelf: 'center', + display: 'flex' + }, + item: { + textAlign: 'center', + margin: '16px' + }, + emoji: { + fontSize: '40px', + margin: '12px' + } + }; +}; + + +/** + * Class for the dialog displayed for E2EE sas verification. + */ +export class ParticipantVerificationDialog extends Component { + /** + * Instantiates a new instance. + * + * @inheritdoc + */ + constructor(props: IProps) { + super(props); + + this._onConfirmed = this._onConfirmed.bind(this); + this._onDismissed = this._onDismissed.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { emoji } = this.props.sas; + const { participantName } = this.props; + + const { classes, t } = this.props; + + return ( + +
+ { t('dialog.verifyParticipantQuestion', { participantName }) } +
+ +
+
+ {/* @ts-ignore */} + {emoji.slice(0, 4).map((e: Array) => + (
+
{ e[0] }
+
{ e[1].charAt(0).toUpperCase() + e[1].slice(1) }
+
))} +
+ +
+ {/* @ts-ignore */} + {emoji.slice(4, 7).map((e: Array) => + (
+
{ e[0] }
+
{ e[1].charAt(0).toUpperCase() + e[1].slice(1) }
+
))} +
+ +
+ +
+ ); + } + + /** + * Notifies this ParticipantVerificationDialog that it has been dismissed by cancel. + * + * @private + * @returns {void} + */ + _onDismissed() { + this.props.dispatch(participantVerified(this.props.pId, false)); + + return true; + } + + /** + * Notifies this ParticipantVerificationDialog that it has been dismissed with confirmation. + * + * @private + * @returns {void} + */ + _onConfirmed() { + this.props.dispatch(participantVerified(this.props.pId, true)); + + return true; + } +} + +/** + * Maps part of the Redux store to the props of this component. + * + * @param {IReduxState} state - The Redux state. + * @param {IProps} ownProps - The own props of the component. + * @returns {IProps} + */ +export function _mapStateToProps(state: IReduxState, ownProps: IProps) { + const participant = getParticipantById(state, ownProps.pId); + + return { + sas: ownProps.sas, + pId: ownProps.pId, + participantName: participant?.name + }; +} + +export default translate(connect(_mapStateToProps)( + + // @ts-ignore + withStyles(styles)(ParticipantVerificationDialog))); diff --git a/react/features/e2ee/functions.ts b/react/features/e2ee/functions.ts index 495171a319..e3c6026fab 100644 --- a/react/features/e2ee/functions.ts +++ b/react/features/e2ee/functions.ts @@ -1,7 +1,9 @@ +import { IReduxState } from '../app/types'; import { IStateful } from '../base/app/types'; -import { getParticipantCount } from '../base/participants/functions'; +import { getParticipantById, getParticipantCount } from '../base/participants/functions'; import { toState } from '../base/redux/functions'; + import { MAX_MODE_LIMIT, MAX_MODE_THRESHOLD } from './constants'; /** @@ -55,3 +57,19 @@ export function isMaxModeThresholdReached(stateful: IStateful) { return participantCount >= MAX_MODE_LIMIT + MAX_MODE_THRESHOLD; } + +/** + * Returns whether e2ee is enabled by the backend. + * + * @param {Object} state - The redux state. + * @param {string} pId - The participant id. + * @returns {boolean} + */ +export function displayVerification(state: IReduxState, pId: string) { + const { conference } = state['features/base/conference']; + const participant = getParticipantById(state, pId); + + return Boolean(conference?.isE2EEEnabled() + && participant?.e2eeVerificationAvailable + && participant?.e2eeVerified === undefined); +} diff --git a/react/features/e2ee/middleware.ts b/react/features/e2ee/middleware.ts index 383a513545..500e442c61 100644 --- a/react/features/e2ee/middleware.ts +++ b/react/features/e2ee/middleware.ts @@ -4,6 +4,8 @@ import { IStore } from '../app/types'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes'; import { CONFERENCE_JOINED } from '../base/conference/actionTypes'; import { getCurrentConference } from '../base/conference/functions'; +import { openDialog } from '../base/dialog/actions'; +import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; import { PARTICIPANT_JOINED, PARTICIPANT_LEFT, PARTICIPANT_UPDATED } from '../base/participants/actionTypes'; import { participantUpdated } from '../base/participants/actions'; import { @@ -17,13 +19,15 @@ import MiddlewareRegistry from '../base/redux/MiddlewareRegistry'; import StateListenerRegistry from '../base/redux/StateListenerRegistry'; import { playSound, registerSound, unregisterSound } from '../base/sounds/actions'; -import { SET_MEDIA_ENCRYPTION_KEY, TOGGLE_E2EE } from './actionTypes'; +import { PARTICIPANT_VERIFIED, SET_MEDIA_ENCRYPTION_KEY, START_VERIFICATION, TOGGLE_E2EE } from './actionTypes'; import { setE2EEMaxMode, setEveryoneEnabledE2EE, setEveryoneSupportE2EE, toggleE2EE } from './actions'; +import ParticipantVerificationDialog from './components/ParticipantVerificationDialog'; import { E2EE_OFF_SOUND_ID, E2EE_ON_SOUND_ID, MAX_MODE } from './constants'; import { isMaxModeReached, isMaxModeThresholdReached } from './functions'; import logger from './logger'; import { E2EE_OFF_SOUND_FILE, E2EE_ON_SOUND_FILE } from './sounds'; + /** * Middleware that captures actions related to E2EE. * @@ -239,6 +243,18 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { break; } + + case PARTICIPANT_VERIFIED: { + const { isVerified, pId } = action; + + conference?.markParticipantVerified(pId, isVerified); + break; + } + + case START_VERIFICATION: { + conference?.startVerification(action.pId); + break; + } } return next(action); @@ -254,6 +270,29 @@ StateListenerRegistry.register( if (previousConference) { dispatch(toggleE2EE(false)); } + + conference.on(JitsiConferenceEvents.E2EE_VERIFICATION_AVAILABLE, (pId: string) => { + dispatch(participantUpdated({ + e2eeVerificationAvailable: true, + id: pId + })); + }); + + conference.on(JitsiConferenceEvents.E2EE_VERIFICATION_READY, (pId: string, sas: object) => { + dispatch(openDialog(ParticipantVerificationDialog, { pId, + sas })); + }); + + conference.on(JitsiConferenceEvents.E2EE_VERIFICATION_COMPLETED, + (pId: string, success: boolean, message: string) => { + if (message) { + logger.warn('E2EE_VERIFICATION_COMPLETED warning', message); + } + dispatch(participantUpdated({ + e2eeVerified: success, + id: pId + })); + }); }); /** diff --git a/react/features/e2ee/reducer.ts b/react/features/e2ee/reducer.ts index 7f193920f6..970df250ab 100644 --- a/react/features/e2ee/reducer.ts +++ b/react/features/e2ee/reducer.ts @@ -20,6 +20,10 @@ export interface IE2EEState { maxMode: string; } +export interface ISas { + emoji: Array; +} + /** * Reduces the Redux actions of the feature features/e2ee. */ diff --git a/react/features/video-menu/components/web/ParticipantContextMenu.tsx b/react/features/video-menu/components/web/ParticipantContextMenu.tsx index 1fab81580f..08c13a4441 100644 --- a/react/features/video-menu/components/web/ParticipantContextMenu.tsx +++ b/react/features/video-menu/components/web/ParticipantContextMenu.tsx @@ -18,6 +18,7 @@ import { isParticipantAudioMuted } from '../../../base/tracks/functions'; import ContextMenu from '../../../base/ui/components/web/ContextMenu'; import ContextMenuItemGroup from '../../../base/ui/components/web/ContextMenuItemGroup'; import { getBreakoutRooms, getCurrentRoomId, isInBreakoutRoom } from '../../../breakout-rooms/functions'; +import { displayVerification } from '../../../e2ee/functions'; import { setVolume } from '../../../filmstrip/actions.web'; import { isStageFilmstripAvailable } from '../../../filmstrip/functions.web'; import { isForceMuted } from '../../../participants-pane/functions'; @@ -29,6 +30,7 @@ import { showOverflowDrawer } from '../../../toolbox/functions.web'; import { REMOTE_CONTROL_MENU_STATES } from './RemoteControlButton'; // @ts-ignore import SendToRoomButton from './SendToRoomButton'; +import VerifyParticipantButton from './VerifyParticipantButton'; import { AskToUnmuteButton, @@ -150,6 +152,7 @@ const ParticipantContextMenu = ({ const isBreakoutRoom = useSelector(isInBreakoutRoom); const isModerationSupported = useSelector((state: IReduxState) => isAvModerationSupported()(state)); const stageFilmstrip = useSelector(isStageFilmstripAvailable); + const shouldDisplayVerification = useSelector((state: IReduxState) => displayVerification(state, participant?.id)); const _currentRoomId = useSelector(getCurrentRoomId); const _rooms: Array<{ id: string; }> = Object.values(useSelector(getBreakoutRooms)); @@ -223,6 +226,15 @@ const ParticipantContextMenu = ({ participantID = { _getCurrentParticipantId() } /> ); } + + if (shouldDisplayVerification) { + buttons2.push( + + ); + } + } if (stageFilmstrip) { diff --git a/react/features/video-menu/components/web/VerifyParticipantButton.tsx b/react/features/video-menu/components/web/VerifyParticipantButton.tsx new file mode 100644 index 0000000000..e722f0e837 --- /dev/null +++ b/react/features/video-menu/components/web/VerifyParticipantButton.tsx @@ -0,0 +1,115 @@ +/* eslint-disable lines-around-comment */ +import { withStyles } from '@mui/styles'; +import React, { Component } from 'react'; +import { WithTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; + +import { IReduxState } from '../../../app/types'; +import { translate } from '../../../base/i18n/functions'; +import { IconCheck } from '../../../base/icons/svg'; +import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem'; +import { startVerification } from '../../../e2ee/actions'; + +/** + * The type of the React {@code Component} props of + * {@link VerifyParticipantButton}. + */ +interface IProps extends WithTranslation { + /** + * The redux {@code dispatch} function. + */ + dispatch: Function; + + /** + * The ID of the participant that this button is supposed to verified. + */ + participantID: string; +} + +const styles = () => { + return { + triggerButton: { + padding: '3px !important', + borderRadius: '4px' + }, + + contextMenu: { + position: 'relative' as const, + marginTop: 0, + right: 'auto', + marginRight: '4px', + marginBottom: '4px' + } + }; +}; + +/** + * React {@code Component} for displaying an icon associated with opening the + * the {@code VideoMenu}. + * + * @augments {Component} + */ +class VerifyParticipantButton extends Component { + + /** + * Instantiates a new {@code Component}. + * + * @inheritdoc + */ + constructor(props: IProps) { + super(props); + + this._handleClick = this._handleClick.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { participantID, t } = this.props; + + return ( + + ); + } + + /** + * Handles clicking / pressing the button, and starts the participant verification process. + * + * @private + * @returns {void} + */ + _handleClick() { + const { dispatch, participantID } = this.props; + + dispatch(startVerification(participantID)); + } +} + +/** + * Maps (parts of) the Redux state to the associated {@code RemoteVideoMenuTriggerButton}'s props. + * + * @param {Object} state - The Redux state. + * @param {Object} ownProps - The own props of the component. + * @private + * @returns {IProps} + */ +function _mapStateToProps(state: IReduxState, ownProps: Partial) { + const { participantID } = ownProps; + + return { + _participantID: participantID + }; +} + +export default translate(connect(_mapStateToProps)(withStyles(styles)(VerifyParticipantButton)));