{
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 ? (
+
+ ) : undefined}
+ {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 (
-
+
;
+ 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),
+ }),
+ };
+}