diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 77c299b6b7f..f833ddf8f3e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -425,6 +425,10 @@ "message": "Call Anyway", "description": "Used on a warning dialog to make it clear that it might be risky to call the conversation." }, + "continueCall": { + "message": "Continue Call", + "description": "Used on a warning dialog to make it clear that it might be risky to continue the group call." + }, "noLongerVerified": { "message": "Your safety number with $name$ has changed and is no longer verified. Click to show.", "description": "Shown in conversation banner when user's safety number has changed, but they were previously verified.", diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js index 792d4dd800a..57c84667f5c 100644 --- a/js/signal_protocol_store.js +++ b/js/signal_protocol_store.js @@ -904,7 +904,7 @@ // state we had before. return false; }, - async isUntrusted(identifier) { + isUntrusted(identifier) { if (identifier === null || identifier === undefined) { throw new Error('Tried to set verified for undefined/null key'); } diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 1d6009cb762..01cab4e1eb8 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -17,7 +17,9 @@ import { } from '../types/Calling'; import { ConversationTypeType } from '../state/ducks/conversations'; import { Colors, ColorType } from '../types/Colors'; +import { getDefaultConversation } from '../util/getDefaultConversation'; import { setup as setupI18n } from '../../js/modules/i18n'; +import { Props as SafetyNumberViewerProps } from '../state/smart/SafetyNumberViewer'; import enMessages from '../../_locales/en/messages.json'; const i18n = setupI18n('en', enMessages); @@ -68,12 +70,16 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ getGroupCallVideoFrameSource: noop as any, hangUp: action('hang-up'), i18n, + keyChangeOk: action('key-change-ok'), me: { + ...getDefaultConversation({ + color: select('Caller color', Colors, 'ultramarine' as ColorType), + title: text('Caller Title', 'Morty Smith'), + }), uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541', - color: select('Caller color', Colors, 'ultramarine' as ColorType), - title: text('Caller Title', 'Morty Smith'), }, renderDeviceSelection: () =>
, + renderSafetyNumberViewer: (_: SafetyNumberViewerProps) =>
, setGroupCallVideoRequest: action('set-group-call-video-request'), setLocalAudio: action('set-local-audio'), setLocalPreview: action('set-local-preview'), @@ -110,6 +116,7 @@ story.add('Ongoing Group Call', () => ( ...getCommonActiveCallData(), callMode: CallMode.Group, connectionState: GroupCallConnectionState.Connected, + conversationsWithSafetyNumberChanges: [], deviceCount: 0, joinState: GroupCallJoinState.Joined, maxDevices: 5, @@ -145,3 +152,27 @@ story.add('Call Request Needed', () => ( })} /> )); + +story.add('Group call - Safety Number Changed', () => ( + +)); diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 3c5582f3c1f..3ce75ff6c97 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -8,6 +8,10 @@ import { CallingLobby } from './CallingLobby'; import { CallingParticipantsList } from './CallingParticipantsList'; import { CallingPip } from './CallingPip'; import { IncomingCallBar } from './IncomingCallBar'; +import { + SafetyNumberChangeDialog, + SafetyNumberProps, +} from './SafetyNumberChangeDialog'; import { ActiveCallType, CallEndedReason, @@ -24,6 +28,7 @@ import { DeclineCallType, DirectCallStateType, HangUpType, + KeyChangeOkType, SetGroupCallVideoRequestType, SetLocalAudioType, SetLocalPreviewType, @@ -32,9 +37,12 @@ import { StartCallType, } from '../state/ducks/calling'; import { LocalizerType } from '../types/Util'; -import { ColorType } from '../types/Colors'; import { missingCaseError } from '../util/missingCaseError'; +interface MeType extends ConversationType { + uuid: string; +} + export interface PropsType { activeCall?: ActiveCallType; availableCameras: Array; @@ -48,21 +56,15 @@ export interface PropsType { call: DirectCallStateType; conversation: ConversationType; }; + keyChangeOk: (_: KeyChangeOkType) => void; renderDeviceSelection: () => JSX.Element; + renderSafetyNumberViewer: (props: SafetyNumberProps) => JSX.Element; startCall: (payload: StartCallType) => void; toggleParticipants: () => void; acceptCall: (_: AcceptCallType) => void; declineCall: (_: DeclineCallType) => void; i18n: LocalizerType; - me: { - avatarPath?: string; - color?: ColorType; - name?: string; - phoneNumber?: string; - profileName?: string; - title: string; - uuid: string; - }; + me: MeType; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; @@ -84,9 +86,11 @@ const ActiveCallManager: React.FC = ({ closeNeedPermissionScreen, hangUp, i18n, + keyChangeOk, getGroupCallVideoFrameSource, me, renderDeviceSelection, + renderSafetyNumberViewer, setGroupCallVideoRequest, setLocalAudio, setLocalPreview, @@ -203,6 +207,7 @@ const ActiveCallManager: React.FC = ({ ) : null} @@ -233,13 +238,11 @@ const ActiveCallManager: React.FC = ({ ...participant, hasAudio: participant.hasRemoteAudio, hasVideo: participant.hasRemoteVideo, - isSelf: false, })), { ...me, hasAudio: hasLocalAudio, hasVideo: hasLocalVideo, - isSelf: true, }, ] : []; @@ -268,9 +271,25 @@ const ActiveCallManager: React.FC = ({ ) : null} + {activeCall.callMode === CallMode.Group && + activeCall.conversationsWithSafetyNumberChanges.length ? ( + { + hangUp({ conversationId: activeCall.conversation.id }); + }} + onConfirm={() => { + keyChangeOk({ conversationId: activeCall.conversation.id }); + }} + renderSafetyNumber={renderSafetyNumberViewer} + /> + ) : null} ); }; diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 06d2df2eddf..7ded542055e 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -12,13 +12,14 @@ import { CallState, GroupCallConnectionState, GroupCallJoinState, - GroupCallPeekedParticipantType, GroupCallRemoteParticipantType, } from '../types/Calling'; +import { ConversationType } from '../state/ducks/conversations'; import { Colors } from '../types/Colors'; import { CallScreen, PropsType } from './CallScreen'; import { setup as setupI18n } from '../../js/modules/i18n'; import { missingCaseError } from '../util/missingCaseError'; +import { getDefaultConversation } from '../util/getDefaultConversation'; import enMessages from '../../_locales/en/messages.json'; const i18n = setupI18n('en', enMessages); @@ -50,7 +51,7 @@ interface DirectCallOverrideProps extends OverridePropsBase { interface GroupCallOverrideProps extends OverridePropsBase { callMode: CallMode.Group; connectionState?: GroupCallConnectionState; - peekedParticipants?: Array; + peekedParticipants?: Array; remoteParticipants?: Array; } @@ -83,6 +84,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({ callMode: CallMode.Group as CallMode.Group, connectionState: overrideProps.connectionState || GroupCallConnectionState.Connected, + conversationsWithSafetyNumberChanges: [], joinState: GroupCallJoinState.Joined, maxDevices: 5, deviceCount: (overrideProps.remoteParticipants || []).length, @@ -240,14 +242,15 @@ story.add('Group call - 1', () => ( callMode: CallMode.Group, remoteParticipants: [ { - uuid: '72fa60e5-25fb-472d-8a56-e56867c57dda', demuxId: 0, hasRemoteAudio: true, hasRemoteVideo: true, - isBlocked: false, - isSelf: false, - title: 'Tyler', videoAspectRatio: 1.3, + ...getDefaultConversation({ + isBlocked: false, + uuid: '72fa60e5-25fb-472d-8a56-e56867c57dda', + title: 'Tyler', + }), }, ], })} @@ -260,34 +263,37 @@ story.add('Group call - Many', () => ( callMode: CallMode.Group, remoteParticipants: [ { - uuid: '094586f5-8fc2-4ce2-a152-2dfcc99f4630', demuxId: 0, hasRemoteAudio: true, hasRemoteVideo: true, - isBlocked: false, - isSelf: false, - title: 'Amy', videoAspectRatio: 1.3, + ...getDefaultConversation({ + isBlocked: false, + title: 'Amy', + uuid: '094586f5-8fc2-4ce2-a152-2dfcc99f4630', + }), }, { - uuid: 'cb5bdb24-4cbb-4650-8a7a-1a2807051e74', demuxId: 1, hasRemoteAudio: true, hasRemoteVideo: true, - isBlocked: false, - isSelf: true, - title: 'Bob', videoAspectRatio: 1.3, + ...getDefaultConversation({ + isBlocked: false, + title: 'Bob', + uuid: 'cb5bdb24-4cbb-4650-8a7a-1a2807051e74', + }), }, { - uuid: '2d7d13ae-53dc-4a51-8dc7-976cd85e0b57', demuxId: 2, hasRemoteAudio: true, hasRemoteVideo: true, - isBlocked: true, - isSelf: false, - title: 'Alice', videoAspectRatio: 1.3, + ...getDefaultConversation({ + isBlocked: true, + title: 'Alice', + uuid: '2d7d13ae-53dc-4a51-8dc7-976cd85e0b57', + }), }, ], })} @@ -301,14 +307,15 @@ story.add('Group call - reconnecting', () => ( connectionState: GroupCallConnectionState.Reconnecting, remoteParticipants: [ { - uuid: '33871c64-0c22-45ce-8aa4-0ec237ac4a31', demuxId: 0, hasRemoteAudio: true, hasRemoteVideo: true, - isBlocked: false, - isSelf: false, - title: 'Tyler', videoAspectRatio: 1.3, + ...getDefaultConversation({ + isBlocked: false, + title: 'Tyler', + uuid: '33871c64-0c22-45ce-8aa4-0ec237ac4a31', + }), }, ], })} diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx index acc00195d2c..012d156f2ae 100644 --- a/ts/components/CallingLobby.stories.tsx +++ b/ts/components/CallingLobby.stories.tsx @@ -11,6 +11,7 @@ import { ColorType } from '../types/Colors'; import { CallingLobby, PropsType } from './CallingLobby'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; +import { getDefaultConversation } from '../util/getDefaultConversation'; const i18n = setupI18n('en', enMessages); @@ -33,7 +34,10 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false), i18n, isGroupCall: boolean('isGroupCall', overrideProps.isGroupCall || false), - me: overrideProps.me || { color: 'ultramarine' as ColorType }, + me: overrideProps.me || { + color: 'ultramarine' as ColorType, + uuid: generateUuid(), + }, onCallCanceled: action('on-call-canceled'), onJoinCall: action('on-join-call'), peekedParticipants: overrideProps.peekedParticipants || [], @@ -48,11 +52,11 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ toggleSettings: action('toggle-settings'), }); -const fakePeekedParticipant = (title: string) => ({ - isSelf: false, - title, - uuid: generateUuid(), -}); +const fakePeekedParticipant = (title: string) => + getDefaultConversation({ + title, + uuid: generateUuid(), + }); const story = storiesOf('Components/CallingLobby', module); @@ -72,8 +76,9 @@ story.add('No Camera, local avatar', () => { const props = createProps({ availableCameras: [], me: { - color: 'ultramarine' as ColorType, avatarPath: '/fixtures/kitten-4-112-112.jpg', + color: 'ultramarine' as ColorType, + uuid: generateUuid(), }, }); return ; diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index cf728452700..24f86bac79a 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -14,6 +14,7 @@ import { CallingHeader } from './CallingHeader'; import { Spinner } from './Spinner'; import { ColorType } from '../types/Colors'; import { LocalizerType } from '../types/Util'; +import { ConversationType } from '../state/ducks/conversations'; export type PropsType = { availableCameras: Array; @@ -28,15 +29,11 @@ export type PropsType = { me: { avatarPath?: string; color?: ColorType; + uuid: string; }; onCallCanceled: () => void; onJoinCall: () => void; - peekedParticipants: Array<{ - firstName?: string; - isSelf: boolean; - title: string; - uuid: string; - }>; + peekedParticipants: Array; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; @@ -124,7 +121,7 @@ export const CallingLobby = ({ // device. // TODO: Improve the "it's you" case; see DESKTOP-926. const participantNames = peekedParticipants.map(participant => - participant.isSelf + participant.uuid === me.uuid ? i18n('you') : participant.firstName || participant.title ); diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index 2542e6d6f50..6534f178edd 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -9,6 +9,7 @@ import { v4 as generateUuid } from 'uuid'; import { CallingParticipantsList, PropsType } from './CallingParticipantsList'; import { Colors } from '../types/Colors'; import { GroupCallRemoteParticipantType } from '../types/Calling'; +import { getDefaultConversation } from '../util/getDefaultConversation'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; @@ -19,24 +20,26 @@ function createParticipant( ): GroupCallRemoteParticipantType { const randomColor = Math.floor(Math.random() * Colors.length - 1); return { - avatarPath: participantProps.avatarPath, - color: Colors[randomColor], demuxId: 2, hasRemoteAudio: Boolean(participantProps.hasRemoteAudio), hasRemoteVideo: Boolean(participantProps.hasRemoteVideo), - isBlocked: Boolean(participantProps.isBlocked), - isSelf: Boolean(participantProps.isSelf), - name: participantProps.name, - profileName: participantProps.title, - title: String(participantProps.title), videoAspectRatio: 1.3, - uuid: generateUuid(), + ...getDefaultConversation({ + avatarPath: participantProps.avatarPath, + color: Colors[randomColor], + isBlocked: Boolean(participantProps.isBlocked), + name: participantProps.name, + profileName: participantProps.title, + title: String(participantProps.title), + uuid: generateUuid(), + }), }; } const createProps = (overrideProps: Partial = {}): PropsType => ({ i18n, onClose: action('on-close'), + ourUuid: 'cf085e6a-e70b-41ec-a310-c198248af13f', participants: overrideProps.participants || [], }); @@ -62,7 +65,6 @@ story.add('Many Participants', () => { const props = createProps({ participants: [ createParticipant({ - isSelf: true, title: 'Son Goku', }), createParticipant({ diff --git a/ts/components/CallingParticipantsList.tsx b/ts/components/CallingParticipantsList.tsx index 1bb62c68775..3db62907440 100644 --- a/ts/components/CallingParticipantsList.tsx +++ b/ts/components/CallingParticipantsList.tsx @@ -9,10 +9,10 @@ import { Avatar } from './Avatar'; import { ContactName } from './conversation/ContactName'; import { InContactsIcon } from './InContactsIcon'; import { LocalizerType } from '../types/Util'; -import { GroupCallPeekedParticipantType } from '../types/Calling'; import { sortByTitle } from '../util/sortByTitle'; +import { ConversationType } from '../state/ducks/conversations'; -interface ParticipantType extends GroupCallPeekedParticipantType { +interface ParticipantType extends ConversationType { hasAudio?: boolean; hasVideo?: boolean; } @@ -20,11 +20,12 @@ interface ParticipantType extends GroupCallPeekedParticipantType { export type PropsType = { readonly i18n: LocalizerType; readonly onClose: () => void; + readonly ourUuid: string; readonly participants: Array; }; export const CallingParticipantsList = React.memo( - ({ i18n, onClose, participants }: PropsType) => { + ({ i18n, onClose, ourUuid, participants }: PropsType) => { const [root, setRoot] = React.useState(null); const sortedParticipants = React.useMemo>( @@ -100,7 +101,7 @@ export const CallingParticipantsList = React.memo( title={participant.title} size={32} /> - {participant.isSelf ? ( + {participant.uuid === ourUuid ? ( {i18n('you')} diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index e254ae81b6d..75b45074d2c 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -105,6 +105,7 @@ story.add('Group Call', () => { ...getCommonActiveCallData(), callMode: CallMode.Group as CallMode.Group, connectionState: GroupCallConnectionState.Connected, + conversationsWithSafetyNumberChanges: [], joinState: GroupCallJoinState.Joined, maxDevices: 5, deviceCount: 0, diff --git a/ts/components/GroupCallRemoteParticipant.stories.tsx b/ts/components/GroupCallRemoteParticipant.stories.tsx index a7b34bda4b1..60ddb9fc3e6 100644 --- a/ts/components/GroupCallRemoteParticipant.stories.tsx +++ b/ts/components/GroupCallRemoteParticipant.stories.tsx @@ -9,6 +9,7 @@ import { GroupCallRemoteParticipant, PropsType, } from './GroupCallRemoteParticipant'; +import { getDefaultConversation } from '../util/getDefaultConversation'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; @@ -37,12 +38,13 @@ const createProps = ( demuxId: 123, hasRemoteAudio: false, hasRemoteVideo: true, - isBlocked: Boolean(isBlocked), - isSelf: false, - title: - 'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso', videoAspectRatio: 1.3, - uuid: '992ed3b9-fc9b-47a9-bdb4-e0c7cbb0fda5', + ...getDefaultConversation({ + isBlocked: Boolean(isBlocked), + title: + 'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso', + uuid: '992ed3b9-fc9b-47a9-bdb4-e0c7cbb0fda5', + }), }, ...overrideProps, }); diff --git a/ts/components/SafetyNumberChangeDialog.tsx b/ts/components/SafetyNumberChangeDialog.tsx index 404bedaebc3..4f5fc66130d 100644 --- a/ts/components/SafetyNumberChangeDialog.tsx +++ b/ts/components/SafetyNumberChangeDialog.tsx @@ -10,7 +10,7 @@ import { InContactsIcon } from './InContactsIcon'; import { ConversationType } from '../state/ducks/conversations'; import { LocalizerType } from '../types/Util'; -type SafetyNumberProps = { +export type SafetyNumberProps = { contactID: string; onClose?: () => void; }; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index a97b62ca465..2beb34f57eb 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1194,6 +1194,7 @@ export class ConversationModel extends window.Backbone.Model< isGroupV1AndDisabled: this.isGroupV1AndDisabled(), isPinned: this.get('isPinned'), isMissingMandatoryProfileSharing: this.isMissingRequiredProfileSharing(), + isUntrusted: this.isUntrusted(), isVerified: this.isVerified(), lastMessage: { status: this.get('lastMessageStatus')!, @@ -1809,64 +1810,50 @@ export class ConversationModel extends window.Backbone.Model< return window.textsecure.storage.protocol.setApproval(this.id, true); } - async safeIsUntrusted(): Promise { - return window.textsecure.storage.protocol - .isUntrusted(this.id) - .catch(() => false); + safeIsUntrusted(): boolean { + try { + return window.textsecure.storage.protocol.isUntrusted(this.id); + } catch (err) { + return false; + } } - async isUntrusted(): Promise { + isUntrusted(): boolean { if (this.isPrivate()) { return this.safeIsUntrusted(); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (!this.contactCollection!.length) { - return Promise.resolve(false); + return false; } - return Promise.all( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.contactCollection!.map(contact => { - if (contact.isMe()) { - return false; - } - return contact.safeIsUntrusted(); - }) - ).then(results => window._.any(results, result => result)); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.contactCollection!.any(contact => { + if (contact.isMe()) { + return false; + } + return contact.safeIsUntrusted(); + }); } - async getUntrusted(): Promise { - // This is a bit ugly because isUntrusted() is async. Could do the work to cache - // it locally, but we really only need it for this call. + getUntrusted(): Backbone.Collection { if (this.isPrivate()) { - return this.isUntrusted().then(untrusted => { - if (untrusted) { - return new window.Backbone.Collection([this]); - } - - return new window.Backbone.Collection(); - }); + if (this.isUntrusted()) { + return new window.Backbone.Collection([this]); + } + return new window.Backbone.Collection(); } - return Promise.all( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.contactCollection!.map(contact => { - if (contact.isMe()) { - return [false, contact]; - } - return Promise.all([contact.isUntrusted(), contact]); - }) - ).then(results => { - const filtered = window._.filter(results, result => { - const untrusted = result[0]; - return untrusted; - }); - return new window.Backbone.Collection( - window._.map(filtered, result => { - const contact = result[1]; - return contact; - }) - ); + + const results = this.contactCollection!.map(contact => { + if (contact.isMe()) { + return [false, contact]; + } + return [contact.isUntrusted(), contact]; }); + + return new window.Backbone.Collection( + results.filter(result => result[0]).map(result => result[1]) + ); } getSentMessageCount(): number { @@ -1983,7 +1970,15 @@ export class ConversationModel extends window.Backbone.Model< }) ); + const isUntrusted = await this.isUntrusted(); + this.trigger('newmessage', model); + + const uuid = this.get('uuid'); + // Group calls are always with folks that have a UUID + if (isUntrusted && uuid) { + window.reduxActions.calling.keyChanged({ uuid }); + } } async addVerifiedChange( diff --git a/ts/services/calling.ts b/ts/services/calling.ts index b9bed598a25..e28bd66f1f3 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -667,6 +667,14 @@ export class CallingClass { return groupCall.getVideoSource(demuxId); } + public resendGroupCallMediaKeys(conversationId: string): void { + const groupCall = this.getGroupCall(conversationId); + if (!groupCall) { + throw new Error('Could not find matching call'); + } + groupCall.resendMediaKeys(); + } + private syncGroupCallToRedux( conversationId: string, groupCall: GroupCall diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 81c782959e1..602c338b962 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -71,9 +71,10 @@ export interface ActiveCallStateType { joinedAt?: number; hasLocalAudio: boolean; hasLocalVideo: boolean; - showParticipantsList: boolean; pip: boolean; settingsDialogOpen: boolean; + safetyNumberChangedUuids: Array; + showParticipantsList: boolean; } export interface CallsByConversationType { @@ -126,6 +127,14 @@ export type HangUpType = { conversationId: string; }; +type KeyChangedType = { + uuid: string; +}; + +export type KeyChangeOkType = { + conversationId: string; +}; + export type IncomingCallType = { conversationId: string; isVideoCall: boolean; @@ -220,6 +229,8 @@ const DECLINE_CALL = 'calling/DECLINE_CALL'; const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE'; const HANG_UP = 'calling/HANG_UP'; const INCOMING_CALL = 'calling/INCOMING_CALL'; +const MARK_CALL_TRUSTED = 'calling/MARK_CALL_TRUSTED'; +const MARK_CALL_UNTRUSTED = 'calling/MARK_CALL_UNTRUSTED'; const OUTGOING_CALL = 'calling/OUTGOING_CALL'; const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED = 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED'; @@ -282,6 +293,18 @@ type IncomingCallActionType = { payload: IncomingCallType; }; +type KeyChangedActionType = { + type: 'calling/MARK_CALL_UNTRUSTED'; + payload: { + safetyNumberChangedUuids: Array; + }; +}; + +type KeyChangeOkActionType = { + type: 'calling/MARK_CALL_TRUSTED'; + payload: null; +}; + type OutgoingCallActionType = { type: 'calling/OUTGOING_CALL'; payload: StartDirectCallType; @@ -353,6 +376,8 @@ export type CallingActionType = | GroupCallStateChangeActionType | HangUpActionType | IncomingCallActionType + | KeyChangedActionType + | KeyChangeOkActionType | OutgoingCallActionType | PeekNotConnectedGroupCallFulfilledActionType | RefreshIODevicesActionType @@ -509,6 +534,56 @@ function hangUp(payload: HangUpType): HangUpActionType { }; } +function keyChanged( + payload: KeyChangedType +): ThunkAction { + return (dispatch, getState) => { + const state = getState(); + const { activeCallState } = state.calling; + + const activeCall = getActiveCall(state.calling); + if (!activeCall || !activeCallState) { + return; + } + + if (activeCall.callMode === CallMode.Group) { + const uuidsChanged = new Set(activeCallState.safetyNumberChangedUuids); + + // Iterate over each participant to ensure that the uuid passed in + // matches one of the participants in the group call. + activeCall.remoteParticipants.forEach(participant => { + if (participant.uuid === payload.uuid) { + uuidsChanged.add(participant.uuid); + } + }); + + const safetyNumberChangedUuids = Array.from(uuidsChanged); + + if (safetyNumberChangedUuids.length) { + dispatch({ + type: MARK_CALL_UNTRUSTED, + payload: { + safetyNumberChangedUuids, + }, + }); + } + } + }; +} + +function keyChangeOk( + payload: KeyChangeOkType +): ThunkAction { + return dispatch => { + calling.resendGroupCallMediaKeys(payload.conversationId); + + dispatch({ + type: MARK_CALL_TRUSTED, + payload: null, + }); + }; +} + function receiveIncomingCall( payload: IncomingCallType ): IncomingCallActionType { @@ -789,6 +864,8 @@ export const actions = { declineCall, groupCallStateChange, hangUp, + keyChanged, + keyChangeOk, receiveIncomingCall, outgoingCall, peekNotConnectedGroupCall, @@ -896,9 +973,10 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, - showParticipantsList: false, pip: false, + safetyNumberChangedUuids: [], settingsDialogOpen: false, + showParticipantsList: false, }, }; } @@ -920,9 +998,10 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, - showParticipantsList: false, pip: false, + safetyNumberChangedUuids: [], settingsDialogOpen: false, + showParticipantsList: false, }, }; } @@ -939,9 +1018,10 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: true, hasLocalVideo: action.payload.asVideoCall, - showParticipantsList: false, pip: false, + safetyNumberChangedUuids: [], settingsDialogOpen: false, + showParticipantsList: false, }, }; } @@ -1003,9 +1083,10 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, - showParticipantsList: false, pip: false, + safetyNumberChangedUuids: [], settingsDialogOpen: false, + showParticipantsList: false, }, }; } @@ -1327,5 +1408,46 @@ export function reducer( }; } + if (action.type === MARK_CALL_UNTRUSTED) { + const { activeCallState } = state; + if (!activeCallState) { + window.log.warn( + 'Cannot mark call as untrusted when there is no active call' + ); + return state; + } + + const { safetyNumberChangedUuids } = action.payload; + + return { + ...state, + activeCallState: { + ...activeCallState, + pip: false, + safetyNumberChangedUuids, + settingsDialogOpen: false, + showParticipantsList: false, + }, + }; + } + + if (action.type === MARK_CALL_TRUSTED) { + const { activeCallState } = state; + if (!activeCallState) { + window.log.warn( + 'Cannot mark call as trusted when there is no active call' + ); + return state; + } + + return { + ...state, + activeCallState: { + ...activeCallState, + safetyNumberChangedUuids: [], + }, + }; + } + return state; } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index a74ea5c5aaa..336cb9c1b0e 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -58,6 +58,7 @@ export type ConversationType = { isBlocked?: boolean; isGroupV1AndDisabled?: boolean; isPinned?: boolean; + isUntrusted?: boolean; isVerified?: boolean; activeAt?: number; timestamp?: number; diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 2dfd7ccf51b..cecd6d40fba 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -15,17 +15,24 @@ import { getIncomingCall } from '../selectors/calling'; import { ActiveCallType, CallMode, - GroupCallPeekedParticipantType, GroupCallRemoteParticipantType, } from '../../types/Calling'; import { StateType } from '../reducer'; import { missingCaseError } from '../../util/missingCaseError'; import { SmartCallingDeviceSelection } from './CallingDeviceSelection'; +import { + SmartSafetyNumberViewer, + Props as SafetyNumberViewerProps, +} from './SafetyNumberViewer'; function renderDeviceSelection(): JSX.Element { return ; } +function renderSafetyNumberViewer(props: SafetyNumberViewerProps): JSX.Element { + return ; +} + const getGroupCallVideoFrameSource = callingService.getGroupCallVideoFrameSource.bind( callingService ); @@ -89,10 +96,9 @@ const mapStateToActiveCallProp = ( ], }; case CallMode.Group: { - const ourUuid = getUserUuid(state); - + const conversationsWithSafetyNumberChanges: Array = []; const remoteParticipants: Array = []; - const peekedParticipants: Array = []; + const peekedParticipants: Array = []; for (let i = 0; i < call.remoteParticipants.length; i += 1) { const remoteParticipant = call.remoteParticipants[i]; @@ -108,23 +114,33 @@ const mapStateToActiveCallProp = ( } remoteParticipants.push({ - avatarPath: remoteConversation.avatarPath, - color: remoteConversation.color, + ...remoteConversation, demuxId: remoteParticipant.demuxId, - firstName: remoteConversation.firstName, hasRemoteAudio: remoteParticipant.hasRemoteAudio, hasRemoteVideo: remoteParticipant.hasRemoteVideo, - isBlocked: Boolean(remoteConversation.isBlocked), - isSelf: remoteParticipant.uuid === ourUuid, - name: remoteConversation.name, - profileName: remoteConversation.profileName, speakerTime: remoteParticipant.speakerTime, - title: remoteConversation.title, - uuid: remoteParticipant.uuid, videoAspectRatio: remoteParticipant.videoAspectRatio, }); } + for ( + let i = 0; + i < activeCallState.safetyNumberChangedUuids.length; + i += 1 + ) { + const uuid = activeCallState.safetyNumberChangedUuids[i]; + + const remoteConversation = conversationSelectorByUuid(uuid); + if (!remoteConversation) { + window.log.error( + 'Remote participant has no corresponding conversation' + ); + continue; + } + + conversationsWithSafetyNumberChanges.push(remoteConversation); + } + for (let i = 0; i < call.peekInfo.uuids.length; i += 1) { const peekedParticipantUuid = call.peekInfo.uuids[i]; @@ -138,22 +154,14 @@ const mapStateToActiveCallProp = ( continue; } - peekedParticipants.push({ - avatarPath: peekedConversation.avatarPath, - color: peekedConversation.color, - firstName: peekedConversation.firstName, - isSelf: peekedParticipantUuid === ourUuid, - name: peekedConversation.name, - profileName: peekedConversation.profileName, - title: peekedConversation.title, - uuid: peekedParticipantUuid, - }); + peekedParticipants.push(peekedConversation); } return { ...baseResult, callMode: CallMode.Group, connectionState: call.connectionState, + conversationsWithSafetyNumberChanges, deviceCount: call.peekInfo.deviceCount, joinState: call.joinState, maxDevices: call.peekInfo.maxDevices, @@ -197,6 +205,7 @@ const mapStateToProps = (state: StateType) => ({ uuid: getUserUuid(state), }, renderDeviceSelection, + renderSafetyNumberViewer, }); const smart = connect(mapStateToProps, mapDispatchToProps); diff --git a/ts/state/smart/SafetyNumberViewer.tsx b/ts/state/smart/SafetyNumberViewer.tsx index 603c6987170..567cee3202f 100644 --- a/ts/state/smart/SafetyNumberViewer.tsx +++ b/ts/state/smart/SafetyNumberViewer.tsx @@ -9,7 +9,7 @@ import { getContactSafetyNumber } from '../selectors/safetyNumber'; import { getConversationSelector } from '../selectors/conversations'; import { getIntl } from '../selectors/user'; -type Props = { +export type Props = { contactID: string; onClose?: () => void; }; diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 2613a43efa1..0846412bc08 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -44,6 +44,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: false, showParticipantsList: false, + safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }, @@ -98,6 +99,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: false, showParticipantsList: false, + safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }, @@ -201,6 +203,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: true, showParticipantsList: false, + safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }); @@ -576,6 +579,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: false, showParticipantsList: false, + safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }); @@ -812,6 +816,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: true, showParticipantsList: false, + safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }); @@ -1046,6 +1051,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: false, showParticipantsList: false, + safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }); diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 2cdfc61700c..06a4d2bb48a 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -41,6 +41,7 @@ describe('state/selectors/calling', () => { hasLocalAudio: true, hasLocalVideo: false, showParticipantsList: false, + safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }, diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 824a4fd35d3..5e69caa6b9d 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -123,7 +123,7 @@ export type StorageProtocolType = StorageType & { clearSignedPreKeysStore: () => Promise; clearSessionStore: () => Promise; isTrustedIdentity: () => void; - isUntrusted: (id: string) => Promise; + isUntrusted: (id: string) => boolean; storePreKey: (keyId: number, keyPair: KeyPairType) => Promise; storeSignedPreKey: ( keyId: number, diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 2f594bff242..00a2aa6bafe 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -1,7 +1,6 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { ColorType } from './Colors'; import { ConversationType } from '../state/ducks/conversations'; // These are strings (1) for the database (2) for Storybook. @@ -19,6 +18,7 @@ interface ActiveCallBaseType { pip: boolean; settingsDialogOpen: boolean; showParticipantsList: boolean; + showSafetyNumberDialog?: boolean; } interface ActiveDirectCallType extends ActiveCallBaseType { @@ -36,10 +36,11 @@ interface ActiveDirectCallType extends ActiveCallBaseType { interface ActiveGroupCallType extends ActiveCallBaseType { callMode: CallMode.Group; connectionState: GroupCallConnectionState; + conversationsWithSafetyNumberChanges: Array; joinState: GroupCallJoinState; maxDevices: number; deviceCount: number; - peekedParticipants: Array; + peekedParticipants: Array; remoteParticipants: Array; } @@ -94,23 +95,10 @@ export enum GroupCallJoinState { Joined = 2, } -export interface GroupCallPeekedParticipantType { - avatarPath?: string; - color?: ColorType; - firstName?: string; - isSelf: boolean; - name?: string; - profileName?: string; - title: string; - uuid: string; -} - -export interface GroupCallRemoteParticipantType - extends GroupCallPeekedParticipantType { +export interface GroupCallRemoteParticipantType extends ConversationType { demuxId: number; hasRemoteAudio: boolean; hasRemoteVideo: boolean; - isBlocked: boolean; speakerTime?: number; videoAspectRatio: number; } diff --git a/ts/util/getDefaultConversation.ts b/ts/util/getDefaultConversation.ts new file mode 100644 index 00000000000..c60962d1aea --- /dev/null +++ b/ts/util/getDefaultConversation.ts @@ -0,0 +1,24 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { v4 as generateUuid } from 'uuid'; +import { ConversationType } from '../state/ducks/conversations'; + +export function getDefaultConversation( + overrideProps: Partial +): ConversationType { + if (window.STORYBOOK_ENV !== 'react') { + throw new Error('getDefaultConversation is for storybook only'); + } + + return { + id: 'guid-1', + lastUpdated: Date.now(), + markedUnread: Boolean(overrideProps.markedUnread), + e164: '+1300555000', + title: 'Alice', + type: 'direct' as const, + uuid: generateUuid(), + ...overrideProps, + }; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 6614e3f2e1d..16d3bca7b66 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14400,7 +14400,7 @@ "rule": "React-useRef", "path": "ts/components/CallingLobby.tsx", "line": " const localVideoRef = React.useRef(null);", - "lineNumber": 67, + "lineNumber": 64, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Used to get the local video element for rendering." diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index fad0a32ed31..0c6409176c6 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -2992,7 +2992,7 @@ Whisper.ConversationView = Whisper.View.extend({ return unverifiedContacts; } - const untrustedContacts = await this.model.getUntrusted(); + const untrustedContacts = this.model.getUntrusted(); if (options.force) { if (untrustedContacts.length) { diff --git a/ts/window.d.ts b/ts/window.d.ts index 3e654d57c57..05c878cc7fb 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -479,6 +479,8 @@ declare global { getServerTrustRoot: () => WhatIsThis; readyForUpdates: () => void; + STORYBOOK_ENV?: string; + // Flags isGroupCallingEnabled: () => boolean; }