diff --git a/_locales/en/messages.json b/_locales/en/messages.json index bdf9e0e59b9..58eab08c9d9 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2845,6 +2845,16 @@ "message": "Start a video call", "description": "Title for the video call button in a conversation" }, + "callNeedPermission": { + "message": "$title$ will get a message request from you. You can call once your message request has been accepted.", + "description": "Shown when a call is rejected because the other party hasn't approved the message/call request", + "placeholders": { + "title": { + "content": "$1", + "example": "Alice" + } + } + }, "callReconnecting": { "message": "Reconnecting...", "description": "Shown in the call screen when the call is reconnecting due to network issues" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 99734e108ee..8880589a069 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5802,6 +5802,8 @@ button.module-image__border-overlay:focus { } } +// Module: Calling + .module-incoming-call { align-items: center; background-color: $color-gray-75; @@ -6022,7 +6024,8 @@ button.module-image__border-overlay:focus { } } -.module-ongoing-call { +.module-ongoing-call, +.module-call-need-permission-screen { background-color: $color-gray-95; height: 100vh; width: 100%; @@ -6254,6 +6257,30 @@ button.module-image__border-overlay:focus { } } +.module-call-need-permission-screen { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: $color-gray-05; + + &__text { + margin: 2em 1em; + max-width: 400px; + @include font-body-1; + text-align: center; + } + + &__button { + padding: 0.5em 1em; + border: 0; + border-radius: 4px; + @include font-body-1-bold; + color: $color-gray-05; + background: $color-gray-65; + } +} + // Module: Left Pane .module-left-pane { diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 6d6c0d291a2..ccac225d521 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -3,7 +3,7 @@ import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { CallManager } from './CallManager'; -import { CallState } from '../types/Calling'; +import { CallEndedReason, CallState } from '../types/Calling'; import { ColorType } from '../types/Colors'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; @@ -27,6 +27,7 @@ const defaultProps = { acceptCall: action('accept-call'), callDetails, callState: CallState.Accepted, + closeNeedPermissionScreen: action('close-need-permission-screen'), declineCall: action('decline-call'), hangUp: action('hang-up'), hasLocalAudio: true, @@ -55,6 +56,13 @@ const permutations = [ callState: CallState.Ringing, }, }, + { + title: 'Call Manager (call request needed)', + props: { + callState: CallState.Ended, + callEndedReason: CallEndedReason.RemoteHangupNeedPermission, + }, + }, ]; storiesOf('Components/CallManager', module).add('Iterations', () => { diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 811c73fa56b..d0ec022f16a 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -1,17 +1,20 @@ import React from 'react'; import { CallingPip } from './CallingPip'; +import { CallNeedPermissionScreen } from './CallNeedPermissionScreen'; import { CallScreen, PropsType as CallScreenPropsType } from './CallScreen'; import { IncomingCallBar, PropsType as IncomingCallBarPropsType, } from './IncomingCallBar'; -import { CallState } from '../types/Calling'; +import { CallState, CallEndedReason } from '../types/Calling'; import { CallDetailsType } from '../state/ducks/calling'; type CallManagerPropsType = { callDetails?: CallDetailsType; callState?: CallState; + callEndedReason?: CallEndedReason; pip: boolean; + closeNeedPermissionScreen: () => void; renderDeviceSelection: () => JSX.Element; settingsDialogOpen: boolean; }; @@ -24,6 +27,8 @@ export const CallManager = ({ acceptCall, callDetails, callState, + callEndedReason, + closeNeedPermissionScreen, declineCall, hangUp, hasLocalAudio, @@ -48,6 +53,20 @@ export const CallManager = ({ const ongoing = callState === CallState.Accepted || callState === CallState.Reconnecting; const ringing = callState === CallState.Ringing; + const ended = callState === CallState.Ended; + + if (ended) { + if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) { + return ( + + ); + } + return null; + } if (outgoing || ongoing) { if (pip) { @@ -98,6 +117,6 @@ export const CallManager = ({ ); } - // Ended || (Incoming && Prering) + // Incoming && Prering return null; }; diff --git a/ts/components/CallNeedPermissionScreen.tsx b/ts/components/CallNeedPermissionScreen.tsx new file mode 100644 index 00000000000..a5dbc1ae90b --- /dev/null +++ b/ts/components/CallNeedPermissionScreen.tsx @@ -0,0 +1,63 @@ +import React, { useRef, useEffect } from 'react'; +import { CallDetailsType } from '../state/ducks/calling'; +import { LocalizerType } from '../types/Util'; +import { Avatar } from './Avatar'; +import { Intl } from './Intl'; +import { ContactName } from './conversation/ContactName'; + +interface Props { + callDetails: CallDetailsType; + i18n: LocalizerType; + close: () => void; +} + +const AUTO_CLOSE_MS = 10000; + +export const CallNeedPermissionScreen: React.FC = ({ + callDetails, + i18n, + close, +}) => { + const title = callDetails.title || i18n('unknownContact'); + + const autoCloseAtRef = useRef(Date.now() + AUTO_CLOSE_MS); + useEffect(() => { + const timeout = setTimeout(close, autoCloseAtRef.current - Date.now()); + return clearTimeout.bind(null, timeout); + }, [autoCloseAtRef, close]); + + return ( +
+ + +

+ ]} + /> +

+ + +
+ ); +}; diff --git a/ts/services/calling.ts b/ts/services/calling.ts index e2a49fd6b56..d5b480cd678 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -672,6 +672,7 @@ export class CallingClass { uxActions.callStateChange({ callState: call.state, callDetails: this.getUxCallDetails(conversation, call), + callEndedReason: call.endedReason, }); }; diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 5e041063f13..489a548a34e 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -1,3 +1,4 @@ +import { CallEndedReason } from 'ringrtc'; import { notify } from '../../services/notify'; import { calling } from '../../services/calling'; import { @@ -35,6 +36,7 @@ export type CallDetailsType = { export type CallingStateType = MediaDeviceSettings & { callDetails?: CallDetailsType; callState?: CallState; + callEndedReason?: CallEndedReason; hasLocalAudio: boolean; hasLocalVideo: boolean; hasRemoteVideo: boolean; @@ -50,6 +52,7 @@ export type AcceptCallType = { export type CallStateChangeType = { callState: CallState; callDetails: CallDetailsType; + callEndedReason?: CallEndedReason; }; export type DeclineCallType = { @@ -97,6 +100,7 @@ const CALL_STATE_CHANGE = 'calling/CALL_STATE_CHANGE'; const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED'; const CHANGE_IO_DEVICE = 'calling/CHANGE_IO_DEVICE'; const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED'; +const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN'; const DECLINE_CALL = 'calling/DECLINE_CALL'; const HANG_UP = 'calling/HANG_UP'; const INCOMING_CALL = 'calling/INCOMING_CALL'; @@ -134,6 +138,11 @@ type ChangeIODeviceFulfilledActionType = { payload: ChangeIODevicePayloadType; }; +type CloseNeedPermissionScreenActionType = { + type: 'calling/CLOSE_NEED_PERMISSION_SCREEN'; + payload: null; +}; + type DeclineCallActionType = { type: 'calling/DECLINE_CALL'; payload: DeclineCallType; @@ -193,6 +202,7 @@ export type CallingActionType = | CallStateChangeFulfilledActionType | ChangeIODeviceActionType | ChangeIODeviceFulfilledActionType + | CloseNeedPermissionScreenActionType | DeclineCallActionType | HangUpActionType | IncomingCallActionType @@ -297,6 +307,13 @@ async function showCallNotification(callDetails: CallDetailsType) { }); } +function closeNeedPermissionScreen(): CloseNeedPermissionScreenActionType { + return { + type: CLOSE_NEED_PERMISSION_SCREEN, + payload: null, + }; +} + function declineCall(payload: DeclineCallType): DeclineCallActionType { calling.decline(payload.callId); @@ -413,6 +430,7 @@ export const actions = { acceptCall, callStateChange, changeIODevice, + closeNeedPermissionScreen, declineCall, hangUp, incomingCall, @@ -438,6 +456,7 @@ function getEmptyState(): CallingStateType { availableSpeakers: [], callDetails: undefined, callState: undefined, + callEndedReason: undefined, hasLocalAudio: false, hasLocalVideo: false, hasRemoteVideo: false, @@ -461,7 +480,11 @@ export function reducer( }; } - if (action.type === DECLINE_CALL || action.type === HANG_UP) { + if ( + action.type === DECLINE_CALL || + action.type === HANG_UP || + action.type === CLOSE_NEED_PERMISSION_SCREEN + ) { return getEmptyState(); } @@ -484,12 +507,19 @@ export function reducer( } if (action.type === CALL_STATE_CHANGE_FULFILLED) { - if (action.payload.callState === CallState.Ended) { + // We want to keep the state around for ended calls if they resulted in a message + // request so we can show the "needs permission" screen. + if ( + action.payload.callState === CallState.Ended && + action.payload.callEndedReason !== + CallEndedReason.RemoteHangupNeedPermission + ) { return getEmptyState(); } return { ...state, callState: action.payload.callState, + callEndedReason: action.payload.callEndedReason, }; } diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 7294664002d..0f0d2af27b3 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -7,6 +7,27 @@ export enum CallState { Ended = 'ended', } +// Must be kept in sync with RingRTC.CallEndedReason +export enum CallEndedReason { + LocalHangup = 'LocalHangup', + RemoteHangup = 'RemoteHangup', + RemoteHangupNeedPermission = 'RemoteHangupNeedPermission', + Declined = 'Declined', + Busy = 'Busy', + Glare = 'Glare', + ReceivedOfferExpired = 'ReceivedOfferExpired', + ReceivedOfferWhileActive = 'ReceivedOfferWhileActive', + ReceivedOfferWithGlare = 'ReceivedOfferWithGlare', + SignalingFailure = 'SignalingFailure', + ConnectionFailure = 'ConnectionFailure', + InternalFailure = 'InternalFailure', + Timeout = 'Timeout', + AcceptedOnAnotherDevice = 'AcceptedOnAnotherDevice', + DeclinedOnAnotherDevice = 'DeclinedOnAnotherDevice', + BusyOnAnotherDevice = 'BusyOnAnotherDevice', + CallerIsNotMultiring = 'CallerIsNotMultiring', +} + // Must be kept in sync with RingRTC.AudioDevice export interface AudioDevice { // Device name.