diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3af377b397..ecb4b57b05 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -447,5 +447,11 @@ "incomingCall": "Incoming call", "accept": "Accept", "decline": "Decline", - "endCall": "End call" + "endCall": "End call", + "micAndCameraPermissionNeededTitle": "Camera and Microphone access required", + "micAndCameraPermissionNeeded": "You can enable microphone and camera access under: Settings (Gear icon) => Privacy", + "unableToCall": "cancel your ongoing call first", + "unableToCallTitle": "Cannot start new call", + "callMissed": "Missed call from $name$", + "callMissedTitle": "Call missed" } diff --git a/ts/components/session/calling/CallContainer.tsx b/ts/components/session/calling/CallContainer.tsx index b947af1166..aff8c21743 100644 --- a/ts/components/session/calling/CallContainer.tsx +++ b/ts/components/session/calling/CallContainer.tsx @@ -135,6 +135,7 @@ export const CallContainer = () => { Call with: {ongoingCallProps.name} +
{hasIncomingCall}
diff --git a/ts/components/session/menu/Menu.tsx b/ts/components/session/menu/Menu.tsx index 37bf13673a..04cc7825c7 100644 --- a/ts/components/session/menu/Menu.tsx +++ b/ts/components/session/menu/Menu.tsx @@ -1,6 +1,10 @@ import React from 'react'; -import { getNumberOfPinnedConversations } from '../../../state/selectors/conversations'; +import { + getHasIncomingCall, + getHasOngoingCall, + getNumberOfPinnedConversations, +} from '../../../state/selectors/conversations'; import { getFocusedSection } from '../../../state/selectors/section'; import { Item, Submenu } from 'react-contexify'; import { @@ -319,32 +323,27 @@ export function getMarkAllReadMenuItem(conversationId: string): JSX.Element | nu } export function getStartCallMenuItem(conversationId: string): JSX.Element | null { - // TODO: possibly conditionally show options? - // const callOptions = [ - // { - // name: 'Video call', - // value: 'video-call', - // }, - // // { - // // name: 'Audio call', - // // value: 'audio-call', - // // }, - // ]; - + const canCall = !(useSelector(getHasIncomingCall) || useSelector(getHasOngoingCall)); return ( { // TODO: either pass param to callRecipient or call different call methods based on item selected. + // TODO: one time redux-persisted permission modal? const convo = getConversationController().get(conversationId); + + if (!canCall) { + ToastUtils.pushUnableToCall(); + return; + } + if (convo) { convo.callState = 'connecting'; await convo.commit(); - await CallManager.USER_callRecipient(convo.id); } }} > - {'video call'} + {'Video Call'} ); } diff --git a/ts/receiver/callMessage.ts b/ts/receiver/callMessage.ts index f2025d8167..7fa2ee4136 100644 --- a/ts/receiver/callMessage.ts +++ b/ts/receiver/callMessage.ts @@ -46,7 +46,7 @@ export async function handleCallMessage( } await removeFromCache(envelope); - CallManager.handleOfferCallMessage(sender, callMessage); + await CallManager.handleOfferCallMessage(sender, callMessage, sentTimestamp); return; } diff --git a/ts/receiver/closedGroups.ts b/ts/receiver/closedGroups.ts index 6725306fa6..3306632a33 100644 --- a/ts/receiver/closedGroups.ts +++ b/ts/receiver/closedGroups.ts @@ -977,7 +977,7 @@ async function sendToGroupMembers( window?.log?.info(`Creating a new group and an encryptionKeyPair for group ${groupPublicKey}`); // evaluating if all invites sent, if failed give the option to retry failed invites via modal dialog const inviteResults = await Promise.all(promises); - const allInvitesSent = _.every(inviteResults, Boolean); + const allInvitesSent = _.every(inviteResults, inviteResult => inviteResult !== false); if (allInvitesSent) { // if (true) { diff --git a/ts/session/sending/MessageQueue.ts b/ts/session/sending/MessageQueue.ts index 1a7cf9db45..d1a248f9a5 100644 --- a/ts/session/sending/MessageQueue.ts +++ b/ts/session/sending/MessageQueue.ts @@ -131,7 +131,7 @@ export class MessageQueue { public async sendToPubKeyNonDurably( user: PubKey, message: ClosedGroupNewMessage | CallMessage - ): Promise { + ): Promise { let rawMessage; try { rawMessage = await MessageUtils.toRawMessage(user, message); @@ -141,7 +141,7 @@ export class MessageQueue { effectiveTimestamp, wrappedEnvelope ); - return !!wrappedEnvelope; + return effectiveTimestamp; } catch (error) { if (rawMessage) { await MessageSentHandler.handleMessageSentFailure(rawMessage, error); diff --git a/ts/session/utils/CallManager.ts b/ts/session/utils/CallManager.ts index 5e9a2ab24a..98491f8aef 100644 --- a/ts/session/utils/CallManager.ts +++ b/ts/session/utils/CallManager.ts @@ -1,4 +1,8 @@ import _ from 'lodash'; +import { ToastUtils } from '.'; +import { SessionSettingCategory } from '../../components/session/settings/SessionSettings'; +import { getConversationById } from '../../data/data'; +import { MessageModelType } from '../../models/messageType'; import { SignalService } from '../../protobuf'; import { answerCall, @@ -7,6 +11,8 @@ import { incomingCall, startingCallWith, } from '../../state/ducks/conversations'; +import { SectionType, showLeftPaneSection, showSettingsSection } from '../../state/ducks/section'; +import { getConversationController } from '../conversations'; import { CallMessage } from '../messages/outgoing/controlMessage/CallMessage'; import { ed25519Str } from '../onions/onionPath'; import { getMessageQueue } from '../sending'; @@ -33,6 +39,7 @@ const ENABLE_VIDEO = true; let makingOffer = false; let ignoreOffer = false; let isSettingRemoteAnswerPending = false; +let lastOutgoingOfferTimestamp = -Infinity; const configuration = { configuration: { @@ -61,14 +68,16 @@ export async function USER_callRecipient(recipient: string) { let mediaDevices: any; try { - const mediaDevices = await openMediaDevices(); - mediaDevices.getTracks().map(track => { + mediaDevices = await openMediaDevices(); + mediaDevices.getTracks().map((track: any) => { window.log.info('USER_callRecipient adding track: ', track); peerConnection?.addTrack(track, mediaDevices); }); } catch (err) { - console.error('Failed to open media devices. Check camera and mic app permissions'); - // TODO: implement toast popup + ToastUtils.pushMicAndCameraPermissionNeeded(() => { + window.inboxStore?.dispatch(showLeftPaneSection(SectionType.Settings)); + window.inboxStore?.dispatch(showSettingsSection(SessionSettingCategory.Privacy)); + }); } peerConnection.addEventListener('connectionstatechange', _event => { window.log.info('peerConnection?.connectionState caller :', peerConnection?.connectionState); @@ -77,7 +86,7 @@ export async function USER_callRecipient(recipient: string) { } }); peerConnection.addEventListener('ontrack', event => { - console.warn('ontrack:', event); + window.log?.warn('ontrack:', event); }); peerConnection.addEventListener('icecandidate', event => { // window.log.warn('event.candidate', event.candidate); @@ -89,42 +98,33 @@ export async function USER_callRecipient(recipient: string) { }); // peerConnection.addEventListener('negotiationneeded', async event => { peerConnection.onnegotiationneeded = async event => { - console.warn('negotiationneeded:', event); + window.log?.warn('negotiationneeded:', event); try { makingOffer = true; - // const offerDescription = await peerConnection?.createOffer({ - // offerToReceiveAudio: true, - // offerToReceiveVideo: true, - // }); - // if (!offerDescription) { - // console.error('Failed to create offer for negotiation'); - // return; - // } - // await peerConnection?.setLocalDescription(offerDescription); - // if (!offerDescription || !offerDescription.sdp || !offerDescription.sdp.length) { - // // window.log.warn(`failed to createOffer for recipient ${ed25519Str(recipient)}`); - // console.warn(`failed to createOffer for recipient ${ed25519Str(recipient)}`); - // return; - // } - // @ts-ignore await peerConnection?.setLocalDescription(); - let offer = await peerConnection?.createOffer(); - console.warn(offer); + const offer = await peerConnection?.createOffer(); + window.log?.warn(offer); if (offer && offer.sdp) { - const callOfferMessage = new CallMessage({ + const negotationOfferMessage = new CallMessage({ timestamp: Date.now(), type: SignalService.CallMessage.Type.OFFER, - // sdps: [offerDescription.sdp], sdps: [offer.sdp], }); window.log.info('sending OFFER MESSAGE'); - await getMessageQueue().sendToPubKeyNonDurably(PubKey.cast(recipient), callOfferMessage); + const negotationOfferSendResult = await getMessageQueue().sendToPubKeyNonDurably( + PubKey.cast(recipient), + negotationOfferMessage + ); + if (typeof negotationOfferSendResult === 'number') { + window.log?.warn('setting last sent timestamp'); + lastOutgoingOfferTimestamp = negotationOfferSendResult; + } + // debug: await new Promise(r => setTimeout(r, 10000)); adding artificial wait for offer debugging } } catch (err) { - console.error(err); window.log?.error(`Error on handling negotiation needed ${err}`); } finally { makingOffer = false; @@ -154,14 +154,21 @@ export async function USER_callRecipient(recipient: string) { return; } await peerConnection.setLocalDescription(offerDescription); - const callOfferMessage = new CallMessage({ + const offerMessage = new CallMessage({ timestamp: Date.now(), type: SignalService.CallMessage.Type.OFFER, sdps: [offerDescription.sdp], }); window.log.info('sending OFFER MESSAGE'); - await getMessageQueue().sendToPubKeyNonDurably(PubKey.cast(recipient), callOfferMessage); + const offerSendResult = await getMessageQueue().sendToPubKeyNonDurably( + PubKey.cast(recipient), + offerMessage + ); + if (typeof offerSendResult === 'number') { + window.log?.warn('setting timestamp'); + lastOutgoingOfferTimestamp = offerSendResult; + } // FIXME audric dispatch UI update to show the calling UI } @@ -255,13 +262,13 @@ export async function USER_acceptIncomingCallRequest(fromSender: string) { const remoteStream = new MediaStream(); peerConnection.addEventListener('icecandidate', event => { - console.warn('icecandidateerror:', event); + window.log?.warn('icecandidateerror:', event); // TODO: ICE stuff // signaler.send({candidate}); // probably event.candidate }); peerConnection.addEventListener('signalingstatechange', event => { - console.warn('signalingstatechange:', event); + window.log?.warn('signalingstatechange:', event); }); if (videoEventsListener) { @@ -363,37 +370,26 @@ export function handleEndCallMessage(sender: string) { export async function handleOfferCallMessage( sender: string, - callMessage: SignalService.CallMessage + callMessage: SignalService.CallMessage, + incomingOfferTimestamp: number ) { try { - console.warn({ callMessage }); + const convos = getConversationController().getConversations(); + if (convos.some(convo => convo.callState !== undefined)) { + await handleMissedCall(sender, incomingOfferTimestamp); + return; + } + const readyForOffer = - !makingOffer && (peerConnection?.signalingState == 'stable' || isSettingRemoteAnswerPending); - // TODO: How should politeness be decided between client / recipient? - ignoreOffer = !true && !readyForOffer; + !makingOffer && (peerConnection?.signalingState === 'stable' || isSettingRemoteAnswerPending); + const polite = lastOutgoingOfferTimestamp < incomingOfferTimestamp; + ignoreOffer = !polite && !readyForOffer; if (ignoreOffer) { // window.log?.warn('Received offer when unready for offer; Ignoring offer.'); - console.warn('Received offer when unready for offer; Ignoring offer.'); + window.log?.warn('Received offer when unready for offer; Ignoring offer.'); return; } - - // const description = await peerConnection?.createOffer({ - // const description = await peerConnection?.createOffer({ - // offerToReceiveVideo: true, - // offerToReceiveAudio: true, - // }) - - // @ts-ignore - await peerConnection?.setLocalDescription(); - console.warn(peerConnection?.localDescription); - - const message = new CallMessage({ - type: SignalService.CallMessage.Type.ANSWER, - timestamp: Date.now(), - }); - - await getMessageQueue().sendToPubKeyNonDurably(PubKey.cast(sender), message); - // TODO: send via our signalling with the sdp of our pc.localDescription + // don't need to do the sending here as we dispatch an answer in a } catch (err) { window.log?.error(`Error handling offer message ${err}`); } @@ -405,6 +401,24 @@ export async function handleOfferCallMessage( window.inboxStore?.dispatch(incomingCall({ pubkey: sender })); } +async function handleMissedCall(sender: string, incomingOfferTimestamp: number) { + const incomingCallConversation = await getConversationById(sender); + ToastUtils.pushedMissedCall(incomingCallConversation?.getNickname() || 'Unknown'); + + await incomingCallConversation?.addSingleMessage({ + conversationId: incomingCallConversation.id, + source: sender, + type: 'incoming' as MessageModelType, + sent_at: incomingOfferTimestamp, + received_at: Date.now(), + expireTimer: 0, + body: 'Missed call', + unread: 1, + }); + incomingCallConversation?.updateLastMessage(); + return; +} + export async function handleCallAnsweredMessage( sender: string, callMessage: SignalService.CallMessage @@ -421,7 +435,7 @@ export async function handleCallAnsweredMessage( window.inboxStore?.dispatch(answerCall({ pubkey: sender })); const remoteDesc = new RTCSessionDescription({ type: 'answer', sdp: callMessage.sdps[0] }); if (peerConnection) { - console.warn('Setting remote answer pending'); + window.log?.warn('Setting remote answer pending'); isSettingRemoteAnswerPending = true; await peerConnection.setRemoteDescription(remoteDesc); isSettingRemoteAnswerPending = false; @@ -455,6 +469,7 @@ export async function handleIceCandidatesMessage( await peerConnection.addIceCandidate(candicate); } catch (err) { if (!ignoreOffer) { + window.log?.warn('Error handling ICE candidates message'); } } } diff --git a/ts/session/utils/Toast.tsx b/ts/session/utils/Toast.tsx index cf3727337e..1ce498724c 100644 --- a/ts/session/utils/Toast.tsx +++ b/ts/session/utils/Toast.tsx @@ -134,6 +134,27 @@ export function pushMessageDeleteForbidden() { pushToastError('messageDeletionForbidden', window.i18n('messageDeletionForbidden')); } +export function pushUnableToCall() { + pushToastError('unableToCall', window.i18n('unableToCallTitle'), window.i18n('unableToCall')); +} + +export function pushedMissedCall(conversationName: string) { + pushToastInfo( + 'missedCall', + window.i18n('callMissedTitle'), + window.i18n('callMissedTitle', conversationName) + ); +} + +export function pushMicAndCameraPermissionNeeded(onClicked: () => void) { + pushToastInfo( + 'micAndCameraPermissionNeeded', + window.i18n('micAndCameraPermissionNeededTitle'), + window.i18n('micAndCameraPermissionNeeded'), + onClicked + ); +} + export function pushAudioPermissionNeeded(onClicked: () => void) { pushToastInfo( 'audioPermissionNeeded',