From b281420a40fd2fadb5ef63c59e11a3c6b73852e0 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Fri, 8 Jan 2021 16:57:54 -0600 Subject: [PATCH] Group calling: add speaker view --- _locales/en/messages.json | 8 ++++ images/icons/v2/grid-view-solid-24.svg | 1 + images/icons/v2/speaker-view-solid-24.svg | 1 + stylesheets/_modules.scss | 32 +++++++++++--- ts/components/CallManager.stories.tsx | 2 + ts/components/CallManager.tsx | 5 ++- ts/components/CallScreen.stories.tsx | 6 +++ ts/components/CallScreen.tsx | 7 ++- ts/components/CallingHeader.tsx | 33 +++++++++++++- ts/components/CallingPip.stories.tsx | 1 + ts/components/GroupCallRemoteParticipants.tsx | 19 +++++++- ts/state/ducks/calling.ts | 44 +++++++++++++++++-- ts/state/smart/CallManager.tsx | 3 +- ts/test-electron/state/ducks/calling_test.ts | 25 ++++++++++- .../state/selectors/calling_test.ts | 3 +- ts/types/Calling.ts | 3 +- 16 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 images/icons/v2/grid-view-solid-24.svg create mode 100644 images/icons/v2/speaker-view-solid-24.svg diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b957a1412..666ad5550 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3147,6 +3147,14 @@ "message": "Fullscreen call", "description": "Title for picture-in-picture toggle" }, + "calling__switch-view--to-grid": { + "message": "Switch to grid view", + "description": "Title for grid/speaker view toggle when on a call" + }, + "calling__switch-view--to-speaker": { + "message": "Switch to speaker view", + "description": "Title for grid/speaker view toggle when on a call" + }, "calling__hangup": { "message": "Leave call", "description": "Title for hang up button" diff --git a/images/icons/v2/grid-view-solid-24.svg b/images/icons/v2/grid-view-solid-24.svg new file mode 100644 index 000000000..2bc702974 --- /dev/null +++ b/images/icons/v2/grid-view-solid-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/speaker-view-solid-24.svg b/images/icons/v2/speaker-view-solid-24.svg new file mode 100644 index 000000000..4e4507558 --- /dev/null +++ b/images/icons/v2/speaker-view-solid-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 43ff393af..107bf243d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -6090,11 +6090,13 @@ button.module-image__border-overlay:focus { } .module-calling-button { + $size: 22px; + &__participants { @include color-svg('../images/icons/v2/group-solid-24.svg', $color-white); display: inline-block; - height: 22px; - width: 22px; + height: $size; + width: $size; &--container { @include button-reset; @@ -6123,14 +6125,32 @@ button.module-image__border-overlay:focus { '../images/icons/v2/settings-solid-16.svg', $color-white ); - height: 22px; - width: 22px; + height: $size; + width: $size; + } + + &__grid-view { + @include color-svg( + '../images/icons/v2/grid-view-solid-24.svg', + $color-white + ); + height: $size; + width: $size; + } + + &__speaker-view { + @include color-svg( + '../images/icons/v2/speaker-view-solid-24.svg', + $color-white + ); + height: $size; + width: $size; } &__pip { @include color-svg('../images/icons/v2/pip-minimize-24.svg', $color-white); - height: 22px; - width: 22px; + height: $size; + width: $size; } } diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 39d0590f8..b197179c0 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -42,6 +42,7 @@ const getCommonActiveCallData = () => ({ joinedAt: Date.now(), hasLocalAudio: boolean('hasLocalAudio', true), hasLocalVideo: boolean('hasLocalVideo', false), + isInSpeakerView: boolean('isInSpeakerView', false), pip: boolean('pip', false), settingsDialogOpen: boolean('settingsDialogOpen', false), showParticipantsList: boolean('showParticipantsList', false), @@ -87,6 +88,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), toggleSettings: action('toggle-settings'), + toggleSpeakerView: action('toggle-speaker-view'), }); const story = storiesOf('Components/CallManager', module); diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 3ce75ff6c..7460853eb 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback } from 'react'; @@ -73,6 +73,7 @@ export interface PropsType { hangUp: (_: HangUpType) => void; togglePip: () => void; toggleSettings: () => void; + toggleSpeakerView: () => void; } interface ActiveCallManagerPropsType extends PropsType { @@ -100,6 +101,7 @@ const ActiveCallManager: React.FC = ({ toggleParticipants, togglePip, toggleSettings, + toggleSpeakerView, }) => { const { conversation, @@ -265,6 +267,7 @@ const ActiveCallManager: React.FC = ({ toggleParticipants={toggleParticipants} togglePip={togglePip} toggleSettings={toggleSettings} + toggleSpeakerView={toggleSpeakerView} /> {settingsDialogOpen && renderDeviceSelection()} {showParticipantsList && activeCall.callMode === CallMode.Group ? ( diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 2f2b5df64..68fe9cecc 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -44,6 +44,7 @@ const conversation = { interface OverridePropsBase { hasLocalAudio?: boolean; hasLocalVideo?: boolean; + isInSpeakerView?: boolean; } interface DirectCallOverrideProps extends OverridePropsBase { @@ -113,6 +114,10 @@ const createActiveCallProp = ( 'hasLocalVideo', overrideProps.hasLocalVideo || false ), + isInSpeakerView: boolean( + 'isInSpeakerView', + overrideProps.isInSpeakerView || false + ), pip: false, settingsDialogOpen: false, showParticipantsList: false, @@ -152,6 +157,7 @@ const createProps = ( toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), toggleSettings: action('toggle-settings'), + toggleSpeakerView: action('toggle-speaker-view'), }); const story = storiesOf('Components/CallScreen', module); diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 43f72d863..4ab02bbdd 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useState, useRef, useEffect, useCallback } from 'react'; @@ -53,6 +53,7 @@ export type PropsType = { toggleParticipants: () => void; togglePip: () => void; toggleSettings: () => void; + toggleSpeakerView: () => void; }; export const CallScreen: React.FC = ({ @@ -71,6 +72,7 @@ export const CallScreen: React.FC = ({ toggleParticipants, togglePip, toggleSettings, + toggleSpeakerView, }) => { const { conversation, @@ -190,6 +192,7 @@ export const CallScreen: React.FC = ({ @@ -244,6 +247,7 @@ export const CallScreen: React.FC = ({ = ({ toggleParticipants={toggleParticipants} togglePip={togglePip} toggleSettings={toggleSettings} + toggleSpeakerView={toggleSpeakerView} /> {remoteParticipantsElement} diff --git a/ts/components/CallingHeader.tsx b/ts/components/CallingHeader.tsx index 2e37f9e37..b9b2f9a1c 100644 --- a/ts/components/CallingHeader.tsx +++ b/ts/components/CallingHeader.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -10,6 +10,7 @@ import { Theme } from '../util/theme'; export type PropsType = { canPip?: boolean; i18n: LocalizerType; + isInSpeakerView?: boolean; isGroupCall?: boolean; message?: string; participantCount: number; @@ -18,11 +19,13 @@ export type PropsType = { toggleParticipants?: () => void; togglePip?: () => void; toggleSettings: () => void; + toggleSpeakerView?: () => void; }; export const CallingHeader = ({ canPip = false, i18n, + isInSpeakerView, isGroupCall = false, message, participantCount, @@ -31,6 +34,7 @@ export const CallingHeader = ({ toggleParticipants, togglePip, toggleSettings, + toggleSpeakerView, }: PropsType): JSX.Element => (
{title ? ( @@ -80,6 +84,33 @@ export const CallingHeader = ({ />
+ {isGroupCall && participantCount > 2 && toggleSpeakerView && ( +
+ +
+ )} {canPip && (
diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 5ed14ab1e..369722ed5 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -39,6 +39,7 @@ const getCommonActiveCallData = () => ({ conversation, hasLocalAudio: boolean('hasLocalAudio', true), hasLocalVideo: boolean('hasLocalVideo', false), + isInSpeakerView: boolean('isInSpeakerView', false), joinedAt: Date.now(), pip: true, settingsDialogOpen: false, diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx index 86a669a0d..24ebc59dd 100644 --- a/ts/components/GroupCallRemoteParticipants.tsx +++ b/ts/components/GroupCallRemoteParticipants.tsx @@ -38,6 +38,7 @@ interface GridArrangement { interface PropsType { getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; i18n: LocalizerType; + isInSpeakerView: boolean; remoteParticipants: ReadonlyArray; setGroupCallVideoRequest: (_: Array) => void; } @@ -68,6 +69,7 @@ interface PropsType { export const GroupCallRemoteParticipants: React.FC = ({ getGroupCallVideoFrameSource, i18n, + isInSpeakerView, remoteParticipants, setGroupCallVideoRequest, }) => { @@ -122,6 +124,14 @@ export const GroupCallRemoteParticipants: React.FC = ({ [remoteParticipants] ); const gridParticipants: Array = useMemo(() => { + if (!sortedParticipants.length) { + return []; + } + + const candidateParticipants = isInSpeakerView + ? [sortedParticipants[0]] + : sortedParticipants; + // Imagine that we laid out all of the rows end-to-end. That's the maximum total // width. So if there were 5 rows and the container was 100px wide, then we can't // possibly fit more than 500px of participants. @@ -130,11 +140,16 @@ export const GroupCallRemoteParticipants: React.FC = ({ // We do the same thing for participants, "laying them out end-to-end" until they // exceed the maximum total width. let totalWidth = 0; - return takeWhile(sortedParticipants, remoteParticipant => { + return takeWhile(candidateParticipants, remoteParticipant => { totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT; return totalWidth < maxTotalWidth; }).sort(stableParticipantComparator); - }, [maxRowCount, containerDimensions.width, sortedParticipants]); + }, [ + containerDimensions.width, + isInSpeakerView, + maxRowCount, + sortedParticipants, + ]); const overflowedParticipants: Array = useMemo( () => sortedParticipants diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index fba6f9864..cca5b5912 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { ThunkAction } from 'redux-thunk'; @@ -68,12 +68,13 @@ export interface GroupCallStateType { export interface ActiveCallStateType { conversationId: string; - joinedAt?: number; hasLocalAudio: boolean; hasLocalVideo: boolean; + isInSpeakerView: boolean; + joinedAt?: number; pip: boolean; - settingsDialogOpen: boolean; safetyNumberChangedUuids: Array; + settingsDialogOpen: boolean; showParticipantsList: boolean; } @@ -243,6 +244,7 @@ const START_DIRECT_CALL = 'calling/START_DIRECT_CALL'; const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS'; const TOGGLE_PIP = 'calling/TOGGLE_PIP'; const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS'; +const TOGGLE_SPEAKER_VIEW = 'calling/TOGGLE_SPEAKER_VIEW'; type AcceptCallPendingActionType = { type: 'calling/ACCEPT_CALL_PENDING'; @@ -365,6 +367,10 @@ type ToggleSettingsActionType = { type: 'calling/TOGGLE_SETTINGS'; }; +type ToggleSpeakerViewActionType = { + type: 'calling/TOGGLE_SPEAKER_VIEW'; +}; + export type CallingActionType = | AcceptCallPendingActionType | CancelCallActionType @@ -389,7 +395,8 @@ export type CallingActionType = | StartDirectCallActionType | ToggleParticipantsActionType | TogglePipActionType - | ToggleSettingsActionType; + | ToggleSettingsActionType + | ToggleSpeakerViewActionType; // Action Creators @@ -856,6 +863,12 @@ function toggleSettings(): ToggleSettingsActionType { }; } +function toggleSpeakerView(): ToggleSpeakerViewActionType { + return { + type: TOGGLE_SPEAKER_VIEW, + }; +} + export const actions = { acceptCall, cancelCall, @@ -884,6 +897,7 @@ export const actions = { toggleParticipants, togglePip, toggleSettings, + toggleSpeakerView, }; export type ActionsType = typeof actions; @@ -974,6 +988,7 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, + isInSpeakerView: false, pip: false, safetyNumberChangedUuids: [], settingsDialogOpen: false, @@ -999,6 +1014,7 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, + isInSpeakerView: false, pip: false, safetyNumberChangedUuids: [], settingsDialogOpen: false, @@ -1019,6 +1035,7 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: true, hasLocalVideo: action.payload.asVideoCall, + isInSpeakerView: false, pip: false, safetyNumberChangedUuids: [], settingsDialogOpen: false, @@ -1084,6 +1101,7 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, + isInSpeakerView: false, pip: false, safetyNumberChangedUuids: [], settingsDialogOpen: false, @@ -1409,6 +1427,24 @@ export function reducer( }; } + if (action.type === TOGGLE_SPEAKER_VIEW) { + const { activeCallState } = state; + if (!activeCallState) { + window.log.warn( + 'Cannot toggle speaker view when there is no active call' + ); + return state; + } + + return { + ...state, + activeCallState: { + ...activeCallState, + isInSpeakerView: !activeCallState.isInSpeakerView, + }, + }; + } + if (action.type === MARK_CALL_UNTRUSTED) { const { activeCallState } = state; if (!activeCallState) { diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index cecd6d40f..f18e7cb37 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -75,6 +75,7 @@ const mapStateToActiveCallProp = ( conversation, hasLocalAudio: activeCallState.hasLocalAudio, hasLocalVideo: activeCallState.hasLocalVideo, + isInSpeakerView: activeCallState.isInSpeakerView, joinedAt: activeCallState.joinedAt, pip: activeCallState.pip, settingsDialogOpen: activeCallState.settingsDialogOpen, diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 0846412bc..9025f7b1c 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; @@ -43,6 +43,7 @@ describe('calling duck', () => { conversationId: 'fake-direct-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, + isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, @@ -98,6 +99,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, + isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, @@ -202,6 +204,7 @@ describe('calling duck', () => { conversationId: 'fake-direct-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, + isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, @@ -578,6 +581,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, + isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, @@ -815,6 +819,7 @@ describe('calling duck', () => { conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: true, + isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, @@ -1050,6 +1055,7 @@ describe('calling duck', () => { conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: false, + isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, @@ -1120,6 +1126,23 @@ describe('calling duck', () => { assert.isTrue(afterThreeToggles.activeCallState?.pip); }); }); + + describe('toggleSpeakerView', () => { + const { toggleSpeakerView } = actions; + + it('toggles speaker view', () => { + const afterOneToggle = reducer( + stateWithActiveGroupCall, + toggleSpeakerView() + ); + const afterTwoToggles = reducer(afterOneToggle, toggleSpeakerView()); + const afterThreeToggles = reducer(afterTwoToggles, toggleSpeakerView()); + + assert.isTrue(afterOneToggle.activeCallState?.isInSpeakerView); + assert.isFalse(afterTwoToggles.activeCallState?.isInSpeakerView); + assert.isTrue(afterThreeToggles.activeCallState?.isInSpeakerView); + }); + }); }); describe('helpers', () => { diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 06a4d2bb4..6b7cf8570 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; @@ -40,6 +40,7 @@ describe('state/selectors/calling', () => { conversationId: 'fake-direct-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, + isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 00a2aa6ba..a31033080 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { ConversationType } from '../state/ducks/conversations'; @@ -14,6 +14,7 @@ interface ActiveCallBaseType { conversation: ConversationType; hasLocalAudio: boolean; hasLocalVideo: boolean; + isInSpeakerView: boolean; joinedAt?: number; pip: boolean; settingsDialogOpen: boolean;