From 0e7f641dc11c65ed99aec7d06129f55fbe46d470 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Wed, 25 Aug 2021 16:42:51 -0500 Subject: [PATCH] Let users ring members when starting a group call Co-Authored-By: Josh Perez <60019601+josh-signal@users.noreply.github.com> --- _locales/en/messages.json | 70 +++++++++++++++- images/icons/v2/ring-28.svg | 1 + preload.js | 2 - stylesheets/_mixins.scss | 5 ++ stylesheets/_modules.scss | 42 +++++++++- stylesheets/components/CallingLobby.scss | 3 +- ts/RemoteConfig.ts | 1 + ts/components/CallManager.stories.tsx | 4 + ts/components/CallManager.tsx | 42 +++++++++- ts/components/CallScreen.stories.tsx | 3 + ts/components/CallScreen.tsx | 73 ++++++++++++----- ts/components/CallingButton.tsx | 27 +++++- ts/components/CallingLobby.stories.tsx | 25 +++++- ts/components/CallingLobby.tsx | 51 +++++++++++- ts/components/CallingPip.stories.tsx | 1 + ts/components/CallingPreCallInfo.stories.tsx | 47 +++++------ ts/components/CallingPreCallInfo.tsx | 80 ++++++++++++------ ts/services/calling.ts | 8 +- ts/state/ducks/calling.ts | 65 ++++++++++++++- ts/state/smart/CallManager.tsx | 5 ++ ts/test-electron/state/ducks/calling_test.ts | 82 +++++++++++++++++++ .../state/selectors/calling_test.ts | 1 + ts/types/Calling.ts | 3 +- ts/util/isGroupCallOutboundRingEnabled.ts | 11 +++ ts/window.d.ts | 1 - 25 files changed, 556 insertions(+), 97 deletions(-) create mode 100644 images/icons/v2/ring-28.svg create mode 100644 ts/util/isGroupCallOutboundRingEnabled.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 4799329fb..cb3c6445a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1368,6 +1368,22 @@ "message": "Stop presenting", "description": "Button tooltip label for stopping screen sharing" }, + "calling__button--ring__label": { + "message": "Ring", + "description": "Label under the ring button" + }, + "calling__button--ring__disabled-because-group-is-too-large": { + "message": "Group is too large to ring the participants.", + "description": "Button tooltip label when you can't ring because the group is too large" + }, + "calling__button--ring__off": { + "message": "Notify, don't ring", + "description": "Button tooltip label for turning ringing off" + }, + "calling__button--ring__on": { + "message": "Enable ringing", + "description": "Button tooltip label for turning ringing on" + }, "calling__your-video-is-off": { "message": "Your camera is off", "description": "Label in the calling lobby indicating that your camera is off" @@ -1450,6 +1466,56 @@ } } }, + "calling__pre-call-info--will-ring-2": { + "message": "Signal will ring $first$ and $second$", + "description": "Shown in the calling lobby to describe who will be rang", + "placeholders": { + "first": { + "content": "$1", + "example": "Sam" + }, + "second": { + "content": "$2", + "example": "Cayce" + } + } + }, + "calling__pre-call-info--will-ring-3": { + "message": "Signal will ring $first$, $second$, and $third$", + "description": "Shown in the calling lobby to describe who will be rang", + "placeholders": { + "first": { + "content": "$1", + "example": "Sam" + }, + "second": { + "content": "$2", + "example": "Cayce" + }, + "third": { + "content": "$3", + "example": "April" + } + } + }, + "calling__pre-call-info--will-ring-many": { + "message": "Signal will ring $first$, $second$, and $others$ others", + "description": "Shown in the calling lobby to describe who will be rang", + "placeholders": { + "person": { + "content": "$1", + "example": "Sam" + }, + "second": { + "content": "$2", + "example": "Cayce" + }, + "others": { + "content": "$3", + "example": "5" + } + } + }, "calling__pre-call-info--will-notify-1": { "message": "$person$ will be notified", "description": "Shown in the calling lobby to describe who will be notified", @@ -3490,10 +3556,6 @@ } } }, - "outgoingCallPrering": { - "message": "Calling...", - "description": "Shown in the call screen when placing an outgoing call that isn't ringing yet" - }, "outgoingCallRinging": { "message": "Ringing...", "description": "Shown in the call screen when placing an outgoing call that is now ringing" diff --git a/images/icons/v2/ring-28.svg b/images/icons/v2/ring-28.svg new file mode 100644 index 000000000..2b667e9fb --- /dev/null +++ b/images/icons/v2/ring-28.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/preload.js b/preload.js index fe079f40d..a112b7ce5 100644 --- a/preload.js +++ b/preload.js @@ -49,8 +49,6 @@ try { window.GV2_MIGRATION_DISABLE_ADD = false; window.GV2_MIGRATION_DISABLE_INVITE = false; - window.RING_WHEN_JOINING_GROUP_CALLS = false; - window.RETRY_DELAY = false; window.platform = process.platform; diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index b5ce80dff..f5300438c 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -229,6 +229,11 @@ text-shadow: 0 0 4px $color-black-alpha-40; } +@mixin lonely-local-video-preview { + object-fit: cover; + opacity: 0.6; +} + // --- Buttons // Individual traits diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 0e1949459..3b6f1fab9 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5362,6 +5362,24 @@ button.module-image__border-overlay:focus { .module-calling-button__container { display: inline-flex; flex-direction: column; + margin-left: 0; + + transition: margin-left 0.3s ease-out, opacity 0.3s ease-out; + @media (prefers-reduced-motion) { + transition: none; + } + + &--hidden { + margin-left: -100px; + opacity: 0; + pointer-events: none; + + // The container could be wider than 100px depending on the label. Hiding the label + // ensures that the above `margin-left` will completely hide the button. + .module-calling-button__label { + display: none; + } + } } .module-calling-button__icon { @@ -5429,6 +5447,19 @@ button.module-image__border-overlay:focus { ); } + &--ring { + $icon: '../images/icons/v2/ring-28.svg'; + &--on { + @include calling-button-icon-on($icon); + } + &--off { + @include calling-button-icon-off($icon); + } + &--disabled { + @include calling-button-icon-disabled($icon); + } + } + &--presenting { $icon: '../images/icons/v2/share-screen-26.svg'; &--on { @@ -5491,7 +5522,7 @@ button.module-image__border-overlay:focus { } &__container { - &--direct { + &--direct:not(&--call-not-started) { .module-ongoing-call__header { position: absolute; } @@ -5517,6 +5548,10 @@ button.module-image__border-overlay:focus { letter-spacing: -0.0025em; } + &__direct-call-ringing-spacer { + flex: 1; + } + &__participants { display: flex; flex: 1 1 0; @@ -5710,6 +5745,11 @@ button.module-image__border-overlay:focus { position: absolute; top: 0; width: 100%; + z-index: -1; + + video { + @include lonely-local-video-preview; + } } &__footer { diff --git a/stylesheets/components/CallingLobby.scss b/stylesheets/components/CallingLobby.scss index 186e28579..f00aeef99 100644 --- a/stylesheets/components/CallingLobby.scss +++ b/stylesheets/components/CallingLobby.scss @@ -3,9 +3,8 @@ .module-CallingLobby { &__local-preview { + @include lonely-local-video-preview; height: 100%; - object-fit: cover; - opacity: 0.6; position: absolute; width: 100%; z-index: -1; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 02d3bbe01..b01333c48 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -9,6 +9,7 @@ export type ConfigKeyType = | 'desktop.announcementGroup' | 'desktop.clientExpiration' | 'desktop.disableGV1' + | 'desktop.groupCallOutboundRing' | 'desktop.groupCalling' | 'desktop.gv2' | 'desktop.internalUser' diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 3d0a60c2c..324d8085c 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -48,6 +48,7 @@ const getCommonActiveCallData = () => ({ hasLocalAudio: boolean('hasLocalAudio', true), hasLocalVideo: boolean('hasLocalVideo', false), isInSpeakerView: boolean('isInSpeakerView', false), + outgoingRing: boolean('outgoingRing', true), pip: boolean('pip', false), settingsDialogOpen: boolean('settingsDialogOpen', false), showParticipantsList: boolean('showParticipantsList', false), @@ -67,7 +68,9 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ getPresentingSources: action('get-presenting-sources'), hangUp: action('hang-up'), i18n, + isGroupCallOutboundRingEnabled: true, keyChangeOk: action('key-change-ok'), + maxGroupCallRingSize: 16, me: { ...getDefaultConversation({ color: select( @@ -90,6 +93,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ setLocalVideo: action('set-local-video'), setPresenting: action('toggle-presenting'), setRendererCanvas: action('set-renderer-canvas'), + setOutgoingRing: action('set-outgoing-ring'), startCall: action('start-call'), stopRingtone: action('stop-ringtone'), toggleParticipants: action('toggle-participants'), diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 622766c95..7f0e51265 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -19,6 +19,7 @@ import { CallEndedReason, CallMode, CallState, + GroupCallConnectionState, GroupCallJoinState, GroupCallVideoRequest, PresentedSource, @@ -41,6 +42,8 @@ import { import { LocalizerType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; +const GROUP_CALL_RING_DURATION = 60 * 1000; + type MeType = ConversationType & { uuid: string; }; @@ -77,6 +80,8 @@ export type PropsType = { bounceAppIconStop: () => unknown; declineCall: (_: DeclineCallType) => void; i18n: LocalizerType; + isGroupCallOutboundRingEnabled: boolean; + maxGroupCallRingSize: number; me: MeType; notifyForCall: (title: string, isVideoCall: boolean) => unknown; openSystemPreferencesAction: () => unknown; @@ -85,6 +90,7 @@ export type PropsType = { setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; + setOutgoingRing: (_: boolean) => void; setPresenting: (_?: PresentedSource) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; stopRingtone: () => unknown; @@ -106,9 +112,11 @@ const ActiveCallManager: React.FC = ({ closeNeedPermissionScreen, hangUp, i18n, + isGroupCallOutboundRingEnabled, keyChangeOk, getGroupCallVideoFrameSource, getPresentingSources, + maxGroupCallRingSize, me, openSystemPreferencesAction, renderDeviceSelection, @@ -119,6 +127,7 @@ const ActiveCallManager: React.FC = ({ setLocalVideo, setPresenting, setRendererCanvas, + setOutgoingRing, startCall, toggleParticipants, togglePip, @@ -136,6 +145,7 @@ const ActiveCallManager: React.FC = ({ presentingSourcesAvailable, settingsDialogOpen, showParticipantsList, + outgoingRing, } = activeCall; const cancelActiveCall = useCallback(() => { @@ -178,7 +188,7 @@ const ActiveCallManager: React.FC = ({ let showCallLobby: boolean; let groupMembers: | undefined - | Array>; + | Array>; switch (activeCall.callMode) { case CallMode.Direct: { @@ -222,14 +232,18 @@ const ActiveCallManager: React.FC = ({ hasLocalVideo={hasLocalVideo} i18n={i18n} isGroupCall={activeCall.callMode === CallMode.Group} + isGroupCallOutboundRingEnabled={isGroupCallOutboundRingEnabled} isCallFull={isCallFull} + maxGroupCallRingSize={maxGroupCallRingSize} me={me} onCallCanceled={cancelActiveCall} onJoinCall={joinActiveCall} + outgoingRing={outgoingRing} peekedParticipants={peekedParticipants} setLocalPreview={setLocalPreview} setLocalAudio={setLocalAudio} setLocalVideo={setLocalVideo} + setOutgoingRing={setOutgoingRing} showParticipantsList={showParticipantsList} toggleParticipants={toggleParticipants} toggleSettings={toggleSettings} @@ -287,6 +301,7 @@ const ActiveCallManager: React.FC = ({ activeCall={activeCall} getPresentingSources={getPresentingSources} getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall} + groupMembers={groupMembers} hangUp={hangUp} i18n={i18n} joinedAt={joinedAt} @@ -354,6 +369,7 @@ export const CallManager: React.FC = props => { notifyForCall, playRingtone, stopRingtone, + setOutgoingRing, } = props; const shouldRing = getShouldRing(props); @@ -369,6 +385,21 @@ export const CallManager: React.FC = props => { return noop; }, [shouldRing, playRingtone, stopRingtone]); + const hasActiveCall = Boolean(activeCall); + const isGroupCall = activeCall?.callMode === CallMode.Group; + useEffect(() => { + if (!hasActiveCall || !isGroupCall) { + return noop; + } + + const timeout = setTimeout(() => { + setOutgoingRing(false); + }, GROUP_CALL_RING_DURATION); + return () => { + clearTimeout(timeout); + }; + }, [hasActiveCall, setOutgoingRing, isGroupCall]); + if (activeCall) { // `props` should logically have an `activeCall` at this point, but TypeScript can't // figure that out, so we pass it in again. @@ -412,7 +443,14 @@ function getShouldRing({ activeCall.callState === CallState.Ringing ); case CallMode.Group: - return false; + return ( + activeCall.outgoingRing && + (activeCall.connectionState === GroupCallConnectionState.Connecting || + activeCall.connectionState === GroupCallConnectionState.Connected) && + activeCall.joinState !== GroupCallJoinState.NotJoined && + !activeCall.remoteParticipants.length && + (activeCall.conversation.sortedGroupMembers || []).length >= 2 + ); default: throw missingCaseError(activeCall); } diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 9725fe6eb..effbf8c5e 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -120,6 +120,7 @@ const createActiveCallProp = ( 'isInSpeakerView', overrideProps.isInSpeakerView || false ), + outgoingRing: true, pip: false, settingsDialogOpen: false, showParticipantsList: false, @@ -147,9 +148,11 @@ const createProps = ( i18n, me: { color: AvatarColors[1], + id: '6146087e-f7ef-457e-9a8d-47df1fdd6b25', name: 'Morty Smith', profileName: 'Morty Smith', title: 'Morty Smith', + uuid: '3c134598-eecb-42ab-9ad3-2b0873f771b2', }, openSystemPreferencesAction: action('open-system-preferences-action'), setGroupCallVideoRequest: action('set-group-call-video-request'), diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 70a7de0a0..eed8f9d56 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -1,7 +1,13 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { + ReactNode, + useState, + useRef, + useEffect, + useCallback, +} from 'react'; import { noop } from 'lodash'; import classNames from 'classnames'; import { @@ -13,6 +19,7 @@ import { } from '../state/ducks/calling'; import { Avatar } from './Avatar'; import { CallingHeader } from './CallingHeader'; +import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo'; import { CallingButton, CallingButtonType } from './CallingButton'; import { CallBackgroundBlur } from './CallBackgroundBlur'; import { @@ -20,11 +27,13 @@ import { CallMode, CallState, GroupCallConnectionState, + GroupCallJoinState, GroupCallVideoRequest, PresentedSource, VideoFrameSource, } from '../types/Calling'; import { AvatarColors, AvatarColorType } from '../types/Colors'; +import type { ConversationType } from '../state/ducks/conversations'; import { CallingToastManager } from './CallingToastManager'; import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants'; @@ -37,16 +46,19 @@ export type PropsType = { activeCall: ActiveCallType; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getPresentingSources: () => void; + groupMembers?: Array>; hangUp: (_: HangUpType) => void; i18n: LocalizerType; joinedAt?: number; me: { avatarPath?: string; color?: AvatarColorType; + id: string; name?: string; phoneNumber?: string; profileName?: string; title: string; + uuid: string; }; openSystemPreferencesAction: () => unknown; setGroupCallVideoRequest: (_: Array) => void; @@ -67,6 +79,7 @@ export const CallScreen: React.FC = ({ activeCall, getGroupCallVideoFrameSource, getPresentingSources, + groupMembers, hangUp, i18n, joinedAt, @@ -198,36 +211,50 @@ export const CallScreen: React.FC = ({ remoteParticipant => remoteParticipant.hasRemoteVideo ); + let isRinging: boolean; + let hasCallStarted: boolean; let headerMessage: string | undefined; let headerTitle: string | undefined; let isConnected: boolean; let participantCount: number; - let remoteParticipantsElement: JSX.Element; + let remoteParticipantsElement: ReactNode; switch (activeCall.callMode) { - case CallMode.Direct: - headerMessage = renderHeaderMessage( + case CallMode.Direct: { + isRinging = + activeCall.callState === CallState.Prering || + activeCall.callState === CallState.Ringing; + hasCallStarted = !isRinging; + headerMessage = renderDirectCallHeaderMessage( i18n, activeCall.callState || CallState.Prering, acceptedDuration ); - headerTitle = conversation.title; + headerTitle = isRinging ? undefined : conversation.title; isConnected = activeCall.callState === CallState.Accepted; participantCount = isConnected ? 2 : 0; - remoteParticipantsElement = ( + remoteParticipantsElement = hasCallStarted ? ( + ) : ( +
); break; + } case CallMode.Group: + isRinging = + activeCall.outgoingRing && !activeCall.remoteParticipants.length; + hasCallStarted = activeCall.joinState !== GroupCallJoinState.NotJoined; participantCount = activeCall.remoteParticipants.length + 1; headerMessage = undefined; - if (currentPresenter) { + if (isRinging) { + headerTitle = undefined; + } else if (currentPresenter) { headerTitle = i18n('calling__presenting--person-ongoing', [ currentPresenter.title, ]); @@ -301,7 +328,10 @@ export const CallScreen: React.FC = ({ 'module-calling__container', `module-ongoing-call__container--${getCallModeClassSuffix( activeCall.callMode - )}` + )}`, + `module-ongoing-call__container--${ + hasCallStarted ? 'call-started' : 'call-not-started' + }` )} onMouseMove={() => { setShowControls(true); @@ -335,6 +365,15 @@ export const CallScreen: React.FC = ({ toggleSpeakerView={toggleSpeakerView} />
+ {isRinging && ( + + )} {remoteParticipantsElement} {isSendingVideo && isLonelyInGroup ? (
@@ -457,22 +496,18 @@ function getCallModeClassSuffix( } } -function renderHeaderMessage( +function renderDirectCallHeaderMessage( i18n: LocalizerType, callState: CallState, acceptedDuration: null | number ): string | undefined { - let message; - if (callState === CallState.Prering) { - message = i18n('outgoingCallPrering'); - } else if (callState === CallState.Ringing) { - message = i18n('outgoingCallRinging'); - } else if (callState === CallState.Reconnecting) { - message = i18n('callReconnecting'); - } else if (callState === CallState.Accepted && acceptedDuration) { - message = i18n('callDuration', [renderDuration(acceptedDuration)]); + if (callState === CallState.Reconnecting) { + return i18n('callReconnecting'); } - return message; + if (callState === CallState.Accepted && acceptedDuration) { + return i18n('callDuration', [renderDuration(acceptedDuration)]); + } + return undefined; } function renderDuration(ms: number): string { diff --git a/ts/components/CallingButton.tsx b/ts/components/CallingButton.tsx index 80ea1c80a..3516b6e3d 100644 --- a/ts/components/CallingButton.tsx +++ b/ts/components/CallingButton.tsx @@ -16,6 +16,9 @@ export enum CallingButtonType { PRESENTING_DISABLED = 'PRESENTING_DISABLED', PRESENTING_OFF = 'PRESENTING_OFF', PRESENTING_ON = 'PRESENTING_ON', + RING_DISABLED = 'RING_DISABLED', + RING_OFF = 'RING_OFF', + RING_ON = 'RING_ON', VIDEO_DISABLED = 'VIDEO_DISABLED', VIDEO_OFF = 'VIDEO_OFF', VIDEO_ON = 'VIDEO_ON', @@ -24,6 +27,7 @@ export enum CallingButtonType { export type PropsType = { buttonType: CallingButtonType; i18n: LocalizerType; + isVisible?: boolean; onClick: () => void; tooltipDirection?: TooltipPlacement; }; @@ -31,6 +35,7 @@ export type PropsType = { export const CallingButton = ({ buttonType, i18n, + isVisible = true, onClick, tooltipDirection, }: PropsType): JSX.Element => { @@ -70,6 +75,21 @@ export const CallingButton = ({ classNameSuffix = 'hangup'; tooltipContent = i18n('calling__hangup'); label = i18n('calling__hangup'); + } else if (buttonType === CallingButtonType.RING_DISABLED) { + classNameSuffix = 'ring--disabled'; + disabled = true; + tooltipContent = i18n( + 'calling__button--ring__disabled-because-group-is-too-large' + ); + label = i18n('calling__button--ring__label'); + } else if (buttonType === CallingButtonType.RING_OFF) { + classNameSuffix = 'ring--off'; + tooltipContent = i18n('calling__button--ring__on'); + label = i18n('calling__button--ring__label'); + } else if (buttonType === CallingButtonType.RING_ON) { + classNameSuffix = 'ring--on'; + tooltipContent = i18n('calling__button--ring__off'); + label = i18n('calling__button--ring__label'); } else if (buttonType === CallingButtonType.PRESENTING_DISABLED) { classNameSuffix = 'presenting--disabled'; tooltipContent = i18n('calling__button--presenting-disabled'); @@ -96,7 +116,12 @@ export const CallingButton = ({ direction={tooltipDirection} theme={Theme.Dark} > -
+