diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 1184f47c5e..af8fe63f7e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4123,6 +4123,14 @@ "messageformat": "Leave", "description": "Title for the hangup button for a group call." }, + "icu:CallControls__JoinLeaveButton--hangup-1-1-tooltip": { + "messageformat": "End call", + "description": "The tooltip for the hangup button in the PIP for 1:1 calls" + }, + "icu:CallControls__JoinLeaveButton--hangup-group-tooltip": { + "messageformat": "Leave call", + "description": "The tooltip for the hangup button in the PIP for group calls" + }, "icu:CallControls__MutedToast--muted": { "messageformat": "Mic off", "description": "Shown in a call when the user mutes their audio input using the Mute toggle button." diff --git a/fixtures/cat-screenshot-3x4.png b/fixtures/cat-screenshot-3x4.png new file mode 100644 index 0000000000..95ccf29620 Binary files /dev/null and b/fixtures/cat-screenshot-3x4.png differ diff --git a/images/icons/v3/maximize/maximize.svg b/images/icons/v3/maximize/maximize.svg index 5a5acd3f6e..0b3527a485 100644 --- a/images/icons/v3/maximize/maximize.svg +++ b/images/icons/v3/maximize/maximize.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v3/minimize/minimize.svg b/images/icons/v3/minimize/minimize.svg index 50a44ab0a2..01ce17774c 100644 --- a/images/icons/v3/minimize/minimize.svg +++ b/images/icons/v3/minimize/minimize.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/package.json b/package.json index f494ae2389..752c7539db 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "prepare-adhoc-build": "node scripts/prepare_adhoc_build.js", "prepare-adhoc-version": "node scripts/prepare_tagged_version.js adhoc", "prepare-staging-build": "node scripts/prepare_staging_build.js", - "prepare-windows-cert": "node scripts/prepare_windows_cert.js", "test": "run-s test-node test-electron test-lint-intl test-eslint", "test-electron": "node ts/scripts/test-electron.js", "test-release": "node ts/scripts/test-release.js", diff --git a/scripts/prepare_windows_cert.js b/scripts/prepare_windows_cert.js deleted file mode 100644 index de0da48137..0000000000 --- a/scripts/prepare_windows_cert.js +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2018 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -const fs = require('fs'); -const _ = require('lodash'); - -const packageJson = require('../package.json'); - -// We have different windows certificates used in each of our build machines, and this -// script makes it easier to ready the app to build on a given machine. - -// ------- - -const KEY = 'build.win.certificateSha1'; -const DEFAULT_VALUE = '8C9A0B5C852EC703D83EF7BFBCEB54B796073759'; - -const BUILDER_A = '507769334DA990A8DDE858314B0CDFC228E7CFA1'; -const BUILDER_B = 'C689B0988CA1A7DF99E4CE4433AC7EA8B82F8D41'; - -let targetValue = DEFAULT_VALUE; - -if (process.env.WINDOWS_BUILDER === 'A') { - targetValue = BUILDER_A; -} -if (process.env.WINDOWS_BUILDER === 'B') { - targetValue = BUILDER_B; -} - -// ------- - -function checkValue(object, objectPath, expected) { - const actual = _.get(object, objectPath); - if (actual !== expected) { - throw new Error(`${objectPath} was ${actual}; expected ${expected}`); - } -} - -// ------ - -checkValue(packageJson, KEY, DEFAULT_VALUE); - -// ------- - -_.set(packageJson, KEY, targetValue); - -// ------- - -fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, ' ')); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 9fbd3937ea..1c5f47d5a8 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3882,14 +3882,17 @@ button.module-image__border-overlay:focus { } &__background { - align-items: center; + position: absolute; + inset-inline-start: 0; + inset-inline-end: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; flex-direction: column; - height: 100%; justify-content: center; overflow: hidden; - position: relative; - width: 100%; &--blur { background-repeat: no-repeat; @@ -3989,9 +3992,10 @@ button.module-image__border-overlay:focus { } .module-ongoing-call { &__remote-video-enabled { - background-color: variables.$color-gray-95; + // TODO: DESKTOP-8537 remove this; we want blurred avatar not all-black letterboxing height: 100%; width: 100%; + position: relative; &--reconnecting { filter: blur(15px); } @@ -4002,6 +4006,7 @@ button.module-image__border-overlay:focus { height: 100vh; width: 100%; display: flex; + position: relative; align-items: center; justify-content: center; } @@ -4104,6 +4109,7 @@ button.module-image__border-overlay:focus { // Only apply container-type: size to grid column to prevent size collapse // for implicitly sized participants (PiP) container-type: size; + position: relative; @container (min-width: 180px) or (min-height: 180px) { .module-ongoing-call__group-call-remote-participant__footer { @@ -4625,14 +4631,17 @@ button.module-image__border-overlay:focus { .module-calling-pip { backface-visibility: hidden; background-color: variables.$color-gray-95; - border-radius: 4px; + border-radius: 18px; box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.05), 0px 8px 20px rgba(0, 0, 0, 0.3); cursor: grab; - height: 158px; + // This is just a starting height; the component will figure out what height it should + // be, given the aspect ratio of the provided video, pinning the width. + // These both should be kept in sync with the height/width in CallingPip.tsx + height: 286px; + width: 160px; position: fixed; - width: 120px; z-index: variables.$z-index-calling-pip; & .module-ongoing-call__group-call-remote-participant { @@ -4643,13 +4652,13 @@ button.module-image__border-overlay:focus { &--remote { align-items: center; background-color: variables.$color-gray-95; - border-radius: 4px 4px 0 0; + border-radius: 18px; + height: 100%; + width: 100%; display: flex; - height: 120px; // This height should be kept in sync with 's hard-coded height. justify-content: center; overflow: hidden; position: relative; - width: 100%; // The avatar image can be dragged on Windows. .module-Avatar img { @@ -4664,15 +4673,20 @@ button.module-image__border-overlay:focus { &--local, &--local-presenting { - bottom: 38px; - height: 32px; position: absolute; - inset-inline-end: 4px; - width: 32px; + top: 8px; + inset-inline-start: 8px; + height: 54px; + width: 80px; + + border-radius: 12px; + overflow: hidden; + background-color: variables.$color-gray-80; video { width: 100%; height: 100%; + object-fit: cover; } } @@ -4681,17 +4695,116 @@ button.module-image__border-overlay:focus { } } - &__actions { - align-items: center; - background-color: variables.$color-gray-02; - border-radius: 0 0 4px 4px; + &__full-size-local-preview { + width: 100%; + position: relative; + + video { + width: 100%; + transform: rotateY(180deg); + } + + &--presenting { + transform: none; + } + } + + &__pills { display: flex; flex-direction: row; - height: 38px; - justify-content: space-around; + align-items: center; - @include mixins.dark-theme { - background-color: variables.$color-gray-65; + position: absolute; + bottom: 66px; + inset-inline-start: 8px; + transition: bottom 0.3s variables.$ease-out-local-preview 0.3s; + + &--no-controls { + bottom: 8px; + } + } + + &__pill { + height: 28px; + border-radius: 14px; + padding: 6px; + padding-inline-start: 12px; + padding-inline-end: 12px; + background-color: variables.$color-gray-80; + color: variables.$color-gray-05; + display: flex; + flex-direction: row; + align-items: center; + + @include mixins.font-body-small; + } + + &__pill-icon { + height: 16px; + width: 16px; + margin-inline-end: 4px; + + &__raised-hands { + @include mixins.color-svg( + '../images/icons/v3/raise_hand/raise_hand.svg', + variables.$color-gray-05 + ); + } + &__group-join { + @include mixins.color-svg( + '../images/icons/v3/person/person-plus-compact.svg', + variables.$color-gray-05 + ); + } + } + + &__actions { + position: absolute; + bottom: 4px; + inset-inline-start: 4px; + inset-inline-end: 4px; + padding: 12px; + height: 56px; + opacity: 0; + transition: opacity 1s ease-in-out; + + &--visible { + opacity: 1; + } + + display: flex; + align-items: center; + flex-direction: row; + + border-radius: 18px; + justify-content: space-around; + background-color: variables.$color-gray-78; + + &__button { + flex-shrink: 0; + flex-grow: 0; + } + &__middle-button { + flex-grow: 1; + text-align: center; + } + + .CallingButton__icon { + height: 32px; + width: 32px; + } + } + + &__un-pip-container { + position: absolute; + top: 16px; + inset-inline-end: 16px; + + opacity: 0; + transition: opacity 1s ease-in-out; + + &--visible { + opacity: 1; } } diff --git a/stylesheets/components/CallingButton.scss b/stylesheets/components/CallingButton.scss index f106cb6f8f..8bc9d4a34b 100644 --- a/stylesheets/components/CallingButton.scss +++ b/stylesheets/components/CallingButton.scss @@ -101,6 +101,14 @@ } } + &--full-screen-call { + @include calling-button-icon( + '../images/icons/v3/pip/pip-maximize-light.svg', + variables.$color-gray-80, + variables.$color-gray-15 + ); + } + &--hangup { @include calling-button-icon( '../images/icons/v3/phone/phone-down-fill-light.svg', @@ -264,3 +272,9 @@ width: 16px; } } + +.module-calling-pip { + .CallingButton__button-container { + margin: 0; + } +} diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index b0c3210679..bbb6a161dd 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -122,8 +122,8 @@ export type PropsType = { sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setIsCallActive: (_: boolean) => void; - setLocalAudio: (_: SetLocalAudioType) => void; - setLocalVideo: (_: SetLocalVideoType) => void; + setLocalAudio: SetLocalAudioType; + setLocalVideo: SetLocalVideoType; setLocalPreviewContainer: (container: HTMLDivElement | null) => void; setOutgoingRing: (_: boolean) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; @@ -345,14 +345,19 @@ function ActiveCallManager({ getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall} imageDataCache={imageDataCache} hangUpActiveCall={hangUpActiveCall} - hasLocalVideo={hasLocalVideo} i18n={i18n} + me={me} setGroupCallVideoRequest={setGroupCallVideoRequestForConversation} setLocalPreviewContainer={setLocalPreviewContainer} setRendererCanvas={setRendererCanvas} switchToPresentationView={switchToPresentationView} switchFromPresentationView={switchFromPresentationView} + toggleAudio={setLocalAudio} togglePip={togglePip} + toggleVideo={() => { + const enabled = !activeCall.hasLocalVideo; + setLocalVideo({ enabled }); + }} /> ); } diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index e1cb9b8eea..2e46f7159c 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -122,8 +122,8 @@ export type PropsType = { _: Array, speakerHeight: number ) => void; - setLocalAudio: (_: SetLocalAudioType) => void; - setLocalVideo: (_: SetLocalVideoType) => void; + setLocalAudio: SetLocalAudioType; + setLocalVideo: SetLocalVideoType; setLocalPreviewContainer: (container: HTMLDivElement | null) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; stickyControls: boolean; @@ -489,10 +489,7 @@ export function CallScreen({ )} > {isSendingVideo ? ( -
+
) : (
diff --git a/ts/components/CallingButton.tsx b/ts/components/CallingButton.tsx index ac2d5b7599..e232d3e541 100644 --- a/ts/components/CallingButton.tsx +++ b/ts/components/CallingButton.tsx @@ -13,6 +13,9 @@ export enum CallingButtonType { AUDIO_DISABLED = 'AUDIO_DISABLED', AUDIO_OFF = 'AUDIO_OFF', AUDIO_ON = 'AUDIO_ON', + FULL_SCREEN_CALL = 'FULL_SCREEN_CALL', + HANGUP_GROUP = 'HANGUP_GROUP', + HANGUP_DIRECT = 'HANGUP_DIRECT', MAXIMIZE = 'MAXIMIZE', MINIMIZE = 'MINIMIZE', MORE_OPTIONS = 'MORE_OPTIONS', @@ -117,6 +120,19 @@ export function CallingButton({ } else if (buttonType === CallingButtonType.MINIMIZE) { classNameSuffix = 'minimize'; tooltipContent = i18n('icu:calling__preview--minimize'); + } else if (buttonType === CallingButtonType.FULL_SCREEN_CALL) { + classNameSuffix = 'full-screen-call'; + tooltipContent = i18n('icu:calling__pip--off'); + } else if (buttonType === CallingButtonType.HANGUP_DIRECT) { + classNameSuffix = 'hangup'; + tooltipContent = i18n( + 'icu:CallControls__JoinLeaveButton--hangup-1-1-tooltip' + ); + } else if (buttonType === CallingButtonType.HANGUP_GROUP) { + classNameSuffix = 'hangup'; + tooltipContent = i18n( + 'icu:CallControls__JoinLeaveButton--hangup-group-tooltip' + ); } const handleClick = React.useCallback( diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index 3c48f01640..ba963575ae 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -72,8 +72,8 @@ export type PropsType = { onJoinCall: () => void; outgoingRing: boolean; peekedParticipants: Array; - setLocalAudio: (_: SetLocalAudioType) => void; - setLocalVideo: (_: SetLocalVideoType) => void; + setLocalAudio: SetLocalAudioType; + setLocalVideo: SetLocalVideoType; setLocalPreviewContainer: (container: HTMLDivElement | null) => void; setOutgoingRing: (_: boolean) => void; showParticipantsList: boolean; diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index 4658b73735..c2356c0cac 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -2,43 +2,17 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import { sample } from 'lodash'; + import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { PropsType } from './CallingParticipantsList'; import { CallingParticipantsList } from './CallingParticipantsList'; -import { AvatarColors } from '../types/Colors'; -import type { GroupCallRemoteParticipantType } from '../types/Calling'; import { generateAci } from '../types/ServiceId'; -import { getDefaultConversationWithServiceId } from '../test-both/helpers/getDefaultConversation'; +import { createCallParticipant } from '../test-both/helpers/createCallParticipant'; const { i18n } = window.SignalContext; -function createParticipant( - participantProps: Partial -): GroupCallRemoteParticipantType { - return { - aci: generateAci(), - demuxId: 2, - hasRemoteAudio: Boolean(participantProps.hasRemoteAudio), - hasRemoteVideo: Boolean(participantProps.hasRemoteVideo), - isHandRaised: Boolean(participantProps.isHandRaised), - mediaKeysReceived: Boolean(participantProps.mediaKeysReceived), - presenting: Boolean(participantProps.presenting), - sharingScreen: Boolean(participantProps.sharingScreen), - videoAspectRatio: 1.3, - ...getDefaultConversationWithServiceId({ - avatarUrl: participantProps.avatarUrl, - color: sample(AvatarColors), - isBlocked: Boolean(participantProps.isBlocked), - name: participantProps.name, - profileName: participantProps.title, - title: String(participantProps.title), - }), - }; -} - const createProps = (overrideProps: Partial = {}): PropsType => ({ i18n, conversationId: 'fake-conversation-id', @@ -60,7 +34,7 @@ export function NoOne(): JSX.Element { export function SoloCall(): JSX.Element { const props = createProps({ participants: [ - createParticipant({ + createCallParticipant({ title: 'Bardock', }), ], @@ -71,37 +45,37 @@ export function SoloCall(): JSX.Element { export function ManyParticipants(): JSX.Element { const props = createProps({ participants: [ - createParticipant({ + createCallParticipant({ title: 'Son Goku', }), - createParticipant({ + createCallParticipant({ hasRemoteAudio: true, hasRemoteVideo: true, presenting: true, name: 'Rage Trunks', title: 'Rage Trunks', }), - createParticipant({ + createCallParticipant({ hasRemoteAudio: true, title: 'Prince Vegeta', }), - createParticipant({ + createCallParticipant({ hasRemoteAudio: true, hasRemoteVideo: true, name: 'Goku Black', title: 'Goku Black', }), - createParticipant({ + createCallParticipant({ isHandRaised: true, title: 'Supreme Kai Zamasu', }), - createParticipant({ + createCallParticipant({ hasRemoteAudio: false, hasRemoteVideo: true, isHandRaised: true, title: 'Chi Chi', }), - createParticipant({ + createCallParticipant({ title: 'Someone With A Really Long Name', }), ], @@ -113,7 +87,7 @@ export function Overflow(): JSX.Element { const props = createProps({ participants: Array(50) .fill(null) - .map(() => createParticipant({ title: 'Kirby' })), + .map(() => createCallParticipant({ title: 'Kirby' })), }); return ; } diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index a42a0cf64e..0439e8f8b8 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -20,9 +20,18 @@ import { CallMode } from '../types/CallDisposition'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource'; import { MINUTE } from '../util/durations'; +import type { SetRendererCanvasType } from '../state/ducks/calling'; +import { createCallParticipant } from '../test-both/helpers/createCallParticipant'; const { i18n } = window.SignalContext; +const videoScreenshot = new Image(300, 400); +videoScreenshot.src = '../../fixtures/cat-screenshot-3x4.png'; +const localPreviewVideo = document.createElement('video'); +localPreviewVideo.autoplay = true; +localPreviewVideo.loop = true; +localPreviewVideo.src = '../../fixtures/pixabay-Soap-Bubble-7141.mp4'; + const conversation: ConversationType = getDefaultConversation({ id: '3051234567', avatarUrl: undefined, @@ -43,7 +52,7 @@ type Overrides = { const getCommonActiveCallData = (overrides: Overrides) => ({ conversation, hasLocalAudio: overrides.hasLocalAudio ?? true, - hasLocalVideo: overrides.hasLocalVideo ?? false, + hasLocalVideo: overrides.hasLocalVideo ?? true, localAudioLevel: overrides.localAudioLevel ?? 0, viewMode: overrides.viewMode ?? CallViewMode.Paginated, joinedAt: Date.now() - MINUTE, @@ -71,21 +80,27 @@ const getDefaultCall = (overrides: Overrides): ActiveDirectCallType => { export default { title: 'Components/CallingPip', - argTypes: { - hasLocalVideo: { control: { type: 'boolean' } }, - }, args: { activeCall: getDefaultCall({}), getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, hangUpActiveCall: action('hang-up-active-call'), - hasLocalVideo: false, i18n, + me: getDefaultConversation({ + name: 'Lonely InGroup', + title: 'Lonely InGroup', + }), setGroupCallVideoRequest: action('set-group-call-video-request'), - setLocalPreviewContainer: action('set-local-preview-container'), - setRendererCanvas: action('set-renderer-canvas'), + setLocalPreviewContainer: (container: HTMLDivElement | null) => { + container?.appendChild(localPreviewVideo); + }, + setRendererCanvas: ({ element }: SetRendererCanvasType) => { + element?.current?.getContext('2d')?.drawImage(videoScreenshot, 0, 0); + }, switchFromPresentationView: action('switch-to-presentation-view'), switchToPresentationView: action('switch-to-presentation-view'), + toggleAudio: action('toggle-audio'), togglePip: action('toggle-pip'), + toggleVideo: action('toggle-video'), }, } satisfies Meta; @@ -93,6 +108,60 @@ export function Default(args: PropsType): JSX.Element { return ; } +// Note: should NOT show speaking indicators +export function DefaultBothSpeaking(args: PropsType): JSX.Element { + return ( + + ); +} + +// Note: should NOT show mute indicator for remote party +export function RemoteMuted(args: PropsType): JSX.Element { + return ( + + ); +} + +// Note: should NOT show show mute indicator in self preview +export function NoLocalAudio(args: PropsType): JSX.Element { + return ( + + ); +} + +export function NoLocalVideo(args: PropsType): JSX.Element { + return ( + + ); +} + export function ContactWithAvatarAndNoVideo(args: PropsType): JSX.Element { return ( ); } + +export function LonelyInGroupCallVideoDisabled(args: PropsType): JSX.Element { + return ( + (), + groupMembers: times(3, () => getDefaultConversation()), + isConversationTooBigToRing: false, + joinState: GroupCallJoinState.Joined, + localDemuxId: 1, + maxDevices: 5, + deviceCount: 0, + peekedParticipants: [], + pendingParticipants: [], + raisedHands: new Set(), + remoteParticipants: [], + remoteAudioLevels: new Map(), + suggestLowerHand: false, + }} + /> + ); +} + +export function GroupCall(args: PropsType): JSX.Element { + return ( + (), + groupMembers: times(3, () => getDefaultConversation()), + isConversationTooBigToRing: false, + joinState: GroupCallJoinState.Joined, + localDemuxId: 1, + maxDevices: 5, + deviceCount: 0, + peekedParticipants: [], + pendingParticipants: [], + raisedHands: new Set(), + remoteParticipants: [ + createCallParticipant({}), + createCallParticipant({}), + ], + remoteAudioLevels: new Map(), + suggestLowerHand: false, + }} + /> + ); +} + +export function GroupCallWithRaisedHands(args: PropsType): JSX.Element { + return ( + (), + groupMembers: times(3, () => getDefaultConversation()), + isConversationTooBigToRing: false, + joinState: GroupCallJoinState.Joined, + localDemuxId: 1, + maxDevices: 5, + deviceCount: 0, + peekedParticipants: [], + pendingParticipants: [], + raisedHands: new Set([1, 2, 3]), + remoteParticipants: [ + createCallParticipant({}), + createCallParticipant({}), + ], + remoteAudioLevels: new Map(), + suggestLowerHand: false, + }} + /> + ); +} + +export function GroupCallWithPendingParticipants(args: PropsType): JSX.Element { + return ( + (), + groupMembers: times(3, () => getDefaultConversation()), + isConversationTooBigToRing: false, + joinState: GroupCallJoinState.Joined, + localDemuxId: 1, + maxDevices: 5, + deviceCount: 0, + peekedParticipants: [], + pendingParticipants: [ + getDefaultConversation(), + getDefaultConversation(), + ], + raisedHands: new Set(), + remoteParticipants: [ + createCallParticipant({}), + createCallParticipant({}), + ], + remoteAudioLevels: new Map(), + suggestLowerHand: false, + }} + /> + ); +} + +export function GroupCallWithPendingAndRaised(args: PropsType): JSX.Element { + return ( + (), + groupMembers: times(3, () => getDefaultConversation()), + isConversationTooBigToRing: false, + joinState: GroupCallJoinState.Joined, + localDemuxId: 1, + maxDevices: 5, + deviceCount: 0, + peekedParticipants: [], + pendingParticipants: [ + getDefaultConversation(), + getDefaultConversation(), + ], + raisedHands: new Set([1, 2, 3]), + remoteParticipants: [ + createCallParticipant({}), + createCallParticipant({}), + ], + remoteAudioLevels: new Map(), + suggestLowerHand: false, + }} + /> + ); +} + +// Note: should NOT show muted indicator for remote party +export function GroupCallRemoteMuted(args: PropsType): JSX.Element { + return ( + (), + groupMembers: times(3, () => getDefaultConversation()), + isConversationTooBigToRing: false, + joinState: GroupCallJoinState.Joined, + localDemuxId: 1, + maxDevices: 5, + deviceCount: 0, + peekedParticipants: [], + pendingParticipants: [ + getDefaultConversation(), + getDefaultConversation(), + ], + raisedHands: new Set([1, 2, 3]), + remoteParticipants: [ + { + ...createCallParticipant({}), + demuxId: 1, + hasRemoteAudio: false, + hasRemoteVideo: true, + mediaKeysReceived: true, + }, + ], + remoteAudioLevels: new Map(), + suggestLowerHand: false, + }} + /> + ); +} + +// Note: should NOT show speaking indicator +export function GroupCallRemoteSpeaking(args: PropsType): JSX.Element { + return ( + (), + groupMembers: times(3, () => getDefaultConversation()), + isConversationTooBigToRing: false, + joinState: GroupCallJoinState.Joined, + localDemuxId: 1, + maxDevices: 5, + deviceCount: 0, + peekedParticipants: [], + pendingParticipants: [ + getDefaultConversation(), + getDefaultConversation(), + ], + raisedHands: new Set([1, 2, 3]), + remoteParticipants: [ + { + ...createCallParticipant({}), + demuxId: 1, + hasRemoteAudio: true, + hasRemoteVideo: true, + mediaKeysReceived: true, + }, + ], + remoteAudioLevels: new Map([[1, 0.75]]), + suggestLowerHand: false, + }} + /> + ); +} diff --git a/ts/components/CallingPip.tsx b/ts/components/CallingPip.tsx index 6ce57d5982..7a95c61bc6 100644 --- a/ts/components/CallingPip.tsx +++ b/ts/components/CallingPip.tsx @@ -2,15 +2,27 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; +import classNames from 'classnames'; import { minBy, debounce, noop } from 'lodash'; + import type { VideoFrameSource } from '@signalapp/ringrtc'; + +import { missingCaseError } from '../util/missingCaseError'; +import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; +import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting'; +import { CallMode } from '../types/CallDisposition'; +import { TooltipPlacement } from './Tooltip'; +import { CallingButton, CallingButtonType } from './CallingButton'; import { CallingPipRemoteVideo } from './CallingPipRemoteVideo'; +import { CallBackgroundBlur } from './CallBackgroundBlur'; + import type { LocalizerType } from '../types/Util'; import type { ActiveCallType, GroupCallVideoRequest } from '../types/Calling'; import type { SetRendererCanvasType } from '../state/ducks/calling'; -import { missingCaseError } from '../util/missingCaseError'; -import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting'; import type { CallingImageDataCache } from './CallManager'; +import type { ConversationType } from '../state/ducks/conversations'; +import { Avatar, AvatarSize } from './Avatar'; +import { AvatarColors } from '../types/Colors'; enum PositionMode { BeingDragged, @@ -50,9 +62,21 @@ export type PropsType = { activeCall: ActiveCallType; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; hangUpActiveCall: (reason: string) => void; - hasLocalVideo: boolean; i18n: LocalizerType; imageDataCache: React.RefObject; + me: Readonly< + Pick< + ConversationType, + | 'avatarUrl' + | 'avatarPlaceholderGradient' + | 'color' + | 'type' + | 'phoneNumber' + | 'profileName' + | 'title' + | 'sharedGroupNames' + > + >; setGroupCallVideoRequest: ( _: Array, speakerHeight: number @@ -61,32 +85,42 @@ export type PropsType = { setRendererCanvas: (_: SetRendererCanvasType) => void; switchToPresentationView: () => void; switchFromPresentationView: () => void; + toggleAudio: () => void; togglePip: () => void; + toggleVideo: () => void; }; -const PIP_HEIGHT = 156; -const PIP_WIDTH = 120; -const PIP_TOP_MARGIN = 56; +const PIP_STARTING_HEIGHT = 286; +const PIP_WIDTH = 160; +const PIP_TOP_MARGIN = 78; const PIP_PADDING = 8; +// Receiving portrait video will cause the PIP to update to match that video size, but +// we need limits +export const PIP_MINIMUM_HEIGHT = 180; +export const PIP_MAXIMUM_HEIGHT = 360; + export function CallingPip({ activeCall, getGroupCallVideoFrameSource, hangUpActiveCall, - hasLocalVideo, imageDataCache, i18n, + me, setGroupCallVideoRequest, setLocalPreviewContainer, setRendererCanvas, switchToPresentationView, switchFromPresentationView, + toggleAudio, togglePip, + toggleVideo, }: PropsType): JSX.Element { const isRTL = i18n.getLocaleDirection() === 'rtl'; const videoContainerRef = React.useRef(null); + const [height, setHeight] = React.useState(PIP_STARTING_HEIGHT); const [windowWidth, setWindowWidth] = React.useState(window.innerWidth); const [windowHeight, setWindowHeight] = React.useState(window.innerHeight); const [positionState, setPositionState] = React.useState({ @@ -112,6 +146,8 @@ export function CallingPip({ mouseX: ev.clientX, mouseY: ev.clientY, })); + ev.preventDefault(); + ev.stopPropagation(); } }, [positionState] @@ -150,7 +186,7 @@ export function CallingPip({ }, { mode: PositionMode.SnapToBottom, - distanceToEdge: innerHeight - (offsetY + PIP_HEIGHT), + distanceToEdge: innerHeight - (offsetY + height), }, ]; @@ -179,7 +215,7 @@ export function CallingPip({ throw missingCaseError(snapTo.mode); } } - }, [isRTL, positionState, setPositionState]); + }, [height, isRTL, positionState, setPositionState]); React.useEffect(() => { if (positionState.mode === PositionMode.BeingDragged) { @@ -213,6 +249,15 @@ export function CallingPip({ }, []); const [translateX, translateY] = React.useMemo<[number, number]>(() => { + const topMin = PIP_TOP_MARGIN; + const bottomMax = windowHeight - PIP_PADDING - height; + + const leftScrollPadding = isRTL ? 1 : 0; + const leftMin = PIP_PADDING + leftScrollPadding; + + const rightScrollPadding = isRTL ? 0 : 1; + const rightMax = windowWidth - PIP_PADDING - PIP_WIDTH - rightScrollPadding; + switch (positionState.mode) { case PositionMode.BeingDragged: return [ @@ -225,56 +270,170 @@ export function CallingPip({ ]; case PositionMode.SnapToLeft: return [ - PIP_PADDING, - Math.min( - positionState.offsetY, - windowHeight - PIP_PADDING - PIP_HEIGHT - ), + leftMin, + Math.max(topMin, Math.min(positionState.offsetY, bottomMax)), ]; case PositionMode.SnapToRight: return [ - windowWidth - PIP_PADDING - PIP_WIDTH, - Math.min( - positionState.offsetY, - windowHeight - PIP_PADDING - PIP_HEIGHT - ), + rightMax, + Math.max(topMin, Math.min(positionState.offsetY, bottomMax)), ]; case PositionMode.SnapToTop: return [ - Math.min( - positionState.offsetX, - windowWidth - PIP_PADDING - PIP_WIDTH - ), - PIP_TOP_MARGIN + PIP_PADDING, + Math.max(leftMin, Math.min(positionState.offsetX, rightMax)), + topMin, ]; case PositionMode.SnapToBottom: return [ - Math.min( - positionState.offsetX, - windowWidth - PIP_PADDING - PIP_WIDTH - ), - windowHeight - PIP_PADDING - PIP_HEIGHT, + Math.max(leftMin, Math.min(positionState.offsetX, rightMax)), + bottomMax, ]; default: throw missingCaseError(positionState); } - }, [isRTL, windowWidth, windowHeight, positionState]); + }, [height, isRTL, windowWidth, windowHeight, positionState]); const localizedTranslateX = isRTL ? -translateX : translateX; + const [showControls, setShowControls] = React.useState(false); + const onMouseEnter = React.useCallback(() => { + setShowControls(true); + }, [setShowControls]); + const onMouseMove = React.useCallback(() => { + setShowControls(true); + }, [setShowControls]); + + const [controlsHover, setControlsHover] = React.useState(false); + const onControlsMouseEnter = React.useCallback(() => { + setControlsHover(true); + }, [setControlsHover]); + + const onControlsMouseLeave = React.useCallback(() => { + setControlsHover(false); + }, [setControlsHover]); + + React.useEffect(() => { + if (!showControls) { + return; + } + if (controlsHover) { + return; + } + + const timer = setTimeout(() => { + setShowControls(false); + }, 2000); + return clearTimeout.bind(null, timer); + }, [showControls, controlsHover, setShowControls]); + const localVideoClassName = activeCall.presentingSource ? 'module-calling-pip__video--local-presenting' : 'module-calling-pip__video--local'; + let raisedHandsCount = 0; + let callJoinRequests = 0; + if (isGroupOrAdhocActiveCall(activeCall)) { + raisedHandsCount = activeCall.raisedHands.size; + callJoinRequests = activeCall.pendingParticipants.length; + } + + let videoButtonType: CallingButtonType; + if (activeCall.presentingSource) { + videoButtonType = CallingButtonType.VIDEO_DISABLED; + } else if (activeCall.hasLocalVideo) { + videoButtonType = CallingButtonType.VIDEO_ON; + } else { + videoButtonType = CallingButtonType.VIDEO_OFF; + } + const audioButtonType = activeCall.hasLocalAudio + ? CallingButtonType.AUDIO_ON + : CallingButtonType.AUDIO_OFF; + const hangupButtonType = + activeCall.callMode === CallMode.Direct + ? CallingButtonType.HANGUP_DIRECT + : CallingButtonType.HANGUP_GROUP; + + let remoteVideoNode: JSX.Element; + const isLonelyInCall = !activeCall.remoteParticipants.length; + const isSendingVideo = + activeCall.hasLocalVideo || activeCall.presentingSource; + if (isLonelyInCall) { + remoteVideoNode = ( +
+ {isSendingVideo ? ( + // TODO: DESKTOP-8537 - when black bars go away, need to make some CSS changes + <> + +
+ + ) : ( + +
+ +
+
+ )} +
+ ); + } else { + remoteVideoNode = ( + { + setHeight(newHeight); + }} + /> + ); + } + return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{ const node = videoContainerRef.current; if (!node) { return; } + const targetNode = ev.target as Element; + if (targetNode?.tagName === 'BUTTON') { + return; + } + const parentNode = targetNode.parentNode as Element; + if (parentNode?.tagName === 'BUTTON') { + return; + } + const rect = node.getBoundingClientRect(); const dragOffsetX = ev.clientX - rect.left; const dragOffsetY = ev.clientY - rect.top; @@ -289,6 +448,7 @@ export function CallingPip({ }} ref={videoContainerRef} style={{ + height: `${height}px`, cursor: positionState.mode === PositionMode.BeingDragged ? '-webkit-grabbing' @@ -300,32 +460,96 @@ export function CallingPip({ : 'transform ease-out 300ms', }} > - - {hasLocalVideo ? ( + {remoteVideoNode} + + {!isLonelyInCall && activeCall.hasLocalVideo ? (
) : null} -
-
+ {raisedHandsCount || callJoinRequests ? ( +
-
- + {raisedHandsCount ? ( +
+
+ {raisedHandsCount} +
+ ) : undefined} + {callJoinRequests ? ( +
+
+ {callJoinRequests} +
+ ) : undefined} +
+ ) : undefined} +
+
+ +
+
+ +
+
+ +
); diff --git a/ts/components/CallingPipRemoteVideo.tsx b/ts/components/CallingPipRemoteVideo.tsx index 951d9fd197..4a5b061e3b 100644 --- a/ts/components/CallingPipRemoteVideo.tsx +++ b/ts/components/CallingPipRemoteVideo.tsx @@ -1,25 +1,25 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useMemo, useEffect } from 'react'; -import { clamp, maxBy } from 'lodash'; +import React, { useEffect } from 'react'; +import { clamp, isNumber, maxBy } from 'lodash'; import type { VideoFrameSource } from '@signalapp/ringrtc'; import { Avatar, AvatarSize } from './Avatar'; import { CallBackgroundBlur } from './CallBackgroundBlur'; import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant'; import type { LocalizerType } from '../types/Util'; -import type { - ActiveCallType, - GroupCallRemoteParticipantType, - GroupCallVideoRequest, +import { + GroupCallJoinState, + type ActiveCallType, + type GroupCallRemoteParticipantType, + type GroupCallVideoRequest, } from '../types/Calling'; -import { GroupCallJoinState } from '../types/Calling'; import { CallMode } from '../types/CallDisposition'; import { AvatarColors } from '../types/Colors'; import type { SetRendererCanvasType } from '../state/ducks/calling'; import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer'; -import { MAX_FRAME_WIDTH } from '../calling/constants'; +import { MAX_FRAME_HEIGHT } from '../calling/constants'; import { usePageVisibility } from '../hooks/usePageVisibility'; import { missingCaseError } from '../util/missingCaseError'; import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant'; @@ -27,21 +27,19 @@ import { isReconnecting } from '../util/callingIsReconnecting'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; import { assertDev } from '../util/assert'; import type { CallingImageDataCache } from './CallManager'; +import { PIP_MAXIMUM_HEIGHT, PIP_MINIMUM_HEIGHT } from './CallingPip'; -// This value should be kept in sync with the hard-coded CSS height. It should also be -// less than `MAX_FRAME_HEIGHT`. -const PIP_VIDEO_HEIGHT_PX = 120; - -function NoVideo({ +function BlurredBackground({ activeCall, + activeGroupCallSpeaker, i18n, }: { activeCall: ActiveCallType; + activeGroupCallSpeaker?: undefined | GroupCallRemoteParticipantType; i18n: LocalizerType; }): JSX.Element { const { avatarPlaceholderGradient, - avatarUrl, color, type: conversationType, phoneNumber, @@ -49,28 +47,28 @@ function NoVideo({ sharedGroupNames, title, } = activeCall.conversation; + const avatarUrl = + activeGroupCallSpeaker?.avatarUrl ?? activeCall.conversation.avatarUrl; return ( -
- -
- -
-
-
+ +
+ +
+
); } @@ -84,6 +82,9 @@ export type PropsType = { speakerHeight: number ) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; + height: number; + width: number; + updateHeight: (newHeight: number) => void; }; export function CallingPipRemoteVideo({ @@ -93,6 +94,9 @@ export function CallingPipRemoteVideo({ i18n, setGroupCallVideoRequest, setRendererCanvas, + height, + width, + updateHeight, }: PropsType): JSX.Element { const { conversation } = activeCall; @@ -101,7 +105,7 @@ export function CallingPipRemoteVideo({ const isPageVisible = usePageVisibility(); const activeGroupCallSpeaker: undefined | GroupCallRemoteParticipantType = - useMemo(() => { + React.useMemo(() => { if (!isGroupOrAdhocActiveCall(activeCall)) { return undefined; } @@ -116,53 +120,83 @@ export function CallingPipRemoteVideo({ }, [activeCall]); useEffect(() => { - if (!isGroupOrAdhocActiveCall(activeCall)) { - return; - } + if (isGroupOrAdhocActiveCall(activeCall)) { + if (!activeGroupCallSpeaker || !activeGroupCallSpeaker.hasRemoteVideo) { + return; + } + const { videoAspectRatio } = activeGroupCallSpeaker; + if (!isNumber(videoAspectRatio)) { + return; + } - if (isPageVisible) { - setGroupCallVideoRequest( - activeCall.remoteParticipants.map(participant => { + const newHeight = clamp( + Math.floor(width * (1 / videoAspectRatio)), + 1, + MAX_FRAME_HEIGHT + ); + // Update only for portrait video that fits, otherwise leave things as they are + if ( + newHeight !== height && + newHeight >= PIP_MINIMUM_HEIGHT && + newHeight <= PIP_MAXIMUM_HEIGHT + ) { + updateHeight(newHeight); + } + + if (isPageVisible) { + const participants = activeCall.remoteParticipants.map(participant => { if (participant === activeGroupCallSpeaker) { return { demuxId: participant.demuxId, - width: clamp( - Math.floor(PIP_VIDEO_HEIGHT_PX * participant.videoAspectRatio), - 1, - MAX_FRAME_WIDTH - ), - height: PIP_VIDEO_HEIGHT_PX, + width, + height: newHeight, }; } return nonRenderedRemoteParticipant(participant); - }), - PIP_VIDEO_HEIGHT_PX - ); + }); + setGroupCallVideoRequest(participants, newHeight); + } else { + setGroupCallVideoRequest( + activeCall.remoteParticipants.map(nonRenderedRemoteParticipant), + 0 + ); + } } else { - setGroupCallVideoRequest( - activeCall.remoteParticipants.map(nonRenderedRemoteParticipant), - 0 - ); + // eslint-disable-next-line no-lonely-if + if (!activeCall.hasRemoteVideo) { + // eslint-disable-next-line no-useless-return + return; + } + // TODO: DESKTOP-8537 - with direct call video stats, call updateHeight as needed } }, [ activeCall, activeGroupCallSpeaker, + height, isPageVisible, setGroupCallVideoRequest, + updateHeight, + width, ]); switch (activeCall.callMode) { case CallMode.Direct: { const { hasRemoteVideo } = activeCall.remoteParticipants[0]; if (!hasRemoteVideo) { - return ; + return ( +
+ +
+ ); } assertDev( conversation.type === 'direct', 'CallingPipRemoteVideo for direct call must be associated with direct conversation' ); + // TODO: DESKTOP-8537 - when black bars go away, we need to make some CSS changes return (
+ ; + return ( +
+ +
+ ); } return (
+ ; -export type SetLocalAudioType = ReadonlyDeep<{ - enabled: boolean; -}>; +// eslint-disable-next-line local-rules/type-alias-readonlydeep +export type SetLocalAudioType = ( + payload?: ReadonlyDeep<{ + enabled: boolean; + }> +) => void; -export type SetLocalVideoType = ReadonlyDeep<{ - enabled: boolean; -}>; +// eslint-disable-next-line local-rules/type-alias-readonlydeep +export type SetLocalVideoType = ( + payload: ReadonlyDeep<{ + enabled: boolean; + }> +) => void; export type SetGroupCallVideoRequestType = ReadonlyDeep<{ conversationId: string; @@ -901,12 +907,12 @@ type SelectPresentingSourceActionType = ReadonlyDeep<{ type SetLocalAudioActionType = ReadonlyDeep<{ type: 'calling/SET_LOCAL_AUDIO_FULFILLED'; - payload: SetLocalAudioType; + payload: Parameters[0]; }>; type SetLocalVideoFulfilledActionType = ReadonlyDeep<{ type: 'calling/SET_LOCAL_VIDEO_FULFILLED'; - payload: SetLocalVideoType; + payload: Parameters[0]; }>; type SetPresentingFulfilledActionType = ReadonlyDeep<{ @@ -1903,26 +1909,28 @@ function setRendererCanvas( } function setLocalAudio( - payload: SetLocalAudioType + payload?: Parameters[0] ): ThunkAction { return (dispatch, getState) => { - const activeCall = getActiveCall(getState().calling); - if (!activeCall) { + const { activeCallState } = getState().calling; + if (!activeCallState || activeCallState.state !== 'Active') { log.warn('Trying to set local audio when no call is active'); return; } - calling.setOutgoingAudio(activeCall.conversationId, payload.enabled); - + const enabled = payload?.enabled ?? !activeCallState.hasLocalAudio; + calling.setOutgoingAudio(activeCallState.conversationId, enabled); dispatch({ type: SET_LOCAL_AUDIO_FULFILLED, - payload, + payload: { + enabled, + }, }); }; } function setLocalVideo( - payload: SetLocalVideoType + payload: Parameters[0] ): ThunkAction { return async (dispatch, getState) => { const activeCall = getActiveCall(getState().calling); @@ -1931,7 +1939,7 @@ function setLocalVideo( return; } - let enabled: boolean; + let enabled = payload?.enabled; if (await requestCameraPermissions()) { if ( isGroupOrAdhocCallState(activeCall) || @@ -1939,14 +1947,13 @@ function setLocalVideo( ) { await calling.setOutgoingVideo( activeCall.conversationId, - payload.enabled + Boolean(payload?.enabled) ); - } else if (payload.enabled) { + } else if (payload?.enabled) { await calling.enableLocalCamera(activeCall.callMode); } else { calling.disableLocalVideo(); } - ({ enabled } = payload); } else { enabled = false; } @@ -1954,8 +1961,7 @@ function setLocalVideo( dispatch({ type: SET_LOCAL_VIDEO_FULFILLED, payload: { - ...payload, - enabled, + enabled: Boolean(enabled), }, }); }; @@ -3994,7 +4000,7 @@ export function reducer( ...state, activeCallState: { ...state.activeCallState, - hasLocalAudio: action.payload.enabled, + hasLocalAudio: Boolean(action.payload?.enabled), }, }; } @@ -4009,7 +4015,7 @@ export function reducer( ...state, activeCallState: { ...state.activeCallState, - hasLocalVideo: action.payload.enabled, + hasLocalVideo: Boolean(action.payload?.enabled), }, }; } diff --git a/ts/test-both/helpers/createCallParticipant.ts b/ts/test-both/helpers/createCallParticipant.ts new file mode 100644 index 0000000000..13a2841ee0 --- /dev/null +++ b/ts/test-both/helpers/createCallParticipant.ts @@ -0,0 +1,34 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { sample } from 'lodash'; + +import { AvatarColors } from '../../types/Colors'; +import type { GroupCallRemoteParticipantType } from '../../types/Calling'; +import { generateAci } from '../../types/ServiceId'; + +import { getDefaultConversationWithServiceId } from './getDefaultConversation'; + +export function createCallParticipant( + participantProps: Partial +): GroupCallRemoteParticipantType { + return { + aci: generateAci(), + demuxId: 2, + hasRemoteAudio: Boolean(participantProps.hasRemoteAudio), + hasRemoteVideo: Boolean(participantProps.hasRemoteVideo), + isHandRaised: Boolean(participantProps.isHandRaised), + mediaKeysReceived: Boolean(participantProps.mediaKeysReceived), + presenting: Boolean(participantProps.presenting), + sharingScreen: Boolean(participantProps.sharingScreen), + videoAspectRatio: 1.3, + ...getDefaultConversationWithServiceId({ + avatarUrl: participantProps.avatarUrl, + color: sample(AvatarColors), + isBlocked: Boolean(participantProps.isBlocked), + name: participantProps.name, + profileName: participantProps.title, + title: String(participantProps.title), + }), + }; +}