Receive side of remote mute

This commit is contained in:
Miriam Zimmerman 2025-05-01 14:26:35 -04:00 committed by GitHub
commit a444790bf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 540 additions and 16 deletions

View file

@ -13529,7 +13529,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see
``` ```
## libsignal-account-keys 0.1.0, libsignal-core 0.1.0, mrp 2.51.0, protobuf 2.51.0, ringrtc 2.51.0, regex-aot 0.1.0, partial-default-derive 0.1.0 ## libsignal-account-keys 0.1.0, libsignal-core 0.1.0, mrp 2.52.0, protobuf 2.52.0, ringrtc 2.52.0, regex-aot 0.1.0, partial-default-derive 0.1.0
``` ```
GNU AFFERO GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE

View file

@ -4164,6 +4164,22 @@
"messageformat": "Mic on", "messageformat": "Mic on",
"description": "Shown in a call when the user is muted and then unmutes their audio input using the Mute toggle button." "description": "Shown in a call when the user is muted and then unmutes their audio input using the Mute toggle button."
}, },
"icu:CallControls__MutedBySomeoneToast": {
"messageformat": "{otherName} muted you.",
"description": "Shown in a call when another client mutes this user"
},
"icu:CallControls__SomeoneMutedSomeoneToast": {
"messageformat": "{name} muted {otherName}.",
"description": "Shown in a call when one client mutes another client"
},
"icu:CallControls__YouMutedSomeoneToast": {
"messageformat": "You muted {otherName}.",
"description": "Shown in a call when you mute another client"
},
"icu:CallControls__YouMutedYourselfToast": {
"messageformat": "You muted yourself from another device.",
"description": "Shown in a call when you joined a call on two devices and mute one from the other."
},
"icu:CallControls__LowerHandSuggestionToast": { "icu:CallControls__LowerHandSuggestionToast": {
"messageformat": "Lower your hand?", "messageformat": "Lower your hand?",
"description": "Shown in a call when the user has their hand raised but has been talking, next to a button to lower their hand." "description": "Shown in a call when the user has their hand raised but has been talking, next to a button to lower their hand."

View file

@ -121,7 +121,7 @@
"@react-types/shared": "3.27.0", "@react-types/shared": "3.27.0",
"@signalapp/libsignal-client": "0.70.0", "@signalapp/libsignal-client": "0.70.0",
"@signalapp/quill-cjs": "2.1.2", "@signalapp/quill-cjs": "2.1.2",
"@signalapp/ringrtc": "2.51.0", "@signalapp/ringrtc": "2.52.0",
"@signalapp/sqlcipher": "2.0.1", "@signalapp/sqlcipher": "2.0.1",
"@tanstack/react-virtual": "3.11.2", "@tanstack/react-virtual": "3.11.2",
"@types/dom-mediacapture-transform": "0.1.11", "@types/dom-mediacapture-transform": "0.1.11",

10
pnpm-lock.yaml generated
View file

@ -132,8 +132,8 @@ importers:
specifier: 2.1.2 specifier: 2.1.2
version: 2.1.2 version: 2.1.2
'@signalapp/ringrtc': '@signalapp/ringrtc':
specifier: 2.51.0 specifier: 2.52.0
version: 2.51.0 version: 2.52.0
'@signalapp/sqlcipher': '@signalapp/sqlcipher':
specifier: 2.0.1 specifier: 2.0.1
version: 2.0.1 version: 2.0.1
@ -2550,8 +2550,8 @@ packages:
resolution: {integrity: sha512-y2sgqdivlrG41J4Zvt/82xtH/PZjDlgItqlD2g/Cv3ZbjlR6cGhTNXbfNygCJB8nXj+C7I28pjt1Zm3k0pv2mg==} resolution: {integrity: sha512-y2sgqdivlrG41J4Zvt/82xtH/PZjDlgItqlD2g/Cv3ZbjlR6cGhTNXbfNygCJB8nXj+C7I28pjt1Zm3k0pv2mg==}
engines: {npm: '>=8.2.3'} engines: {npm: '>=8.2.3'}
'@signalapp/ringrtc@2.51.0': '@signalapp/ringrtc@2.52.0':
resolution: {integrity: sha512-p0S7JLReO9NjfxB3Er3V6eydNB4IUfrIIimtlD7E9CerUWtejxvPNqEPxfKTCL/vVde/pqsIn0Qw9LjysA84xA==} resolution: {integrity: sha512-s4mwnL4f6LBpDonIJ2TZKv1mD+zbAq6aPnPH3tlJZUhrdJj0GZtjTVY2i7Y6dmLG3zsho10a8hGtGjPAMyDmlw==}
'@signalapp/sqlcipher@2.0.1': '@signalapp/sqlcipher@2.0.1':
resolution: {integrity: sha512-7dSgNnf/hrGZfVSGlhVH39f7BDNNOO61tg6Xu/Fa38TCeZj6/U5YILKQavBArCtkahUvzGBV9QIyRr0zereU7A==} resolution: {integrity: sha512-7dSgNnf/hrGZfVSGlhVH39f7BDNNOO61tg6Xu/Fa38TCeZj6/U5YILKQavBArCtkahUvzGBV9QIyRr0zereU7A==}
@ -12072,7 +12072,7 @@ snapshots:
lodash: 4.17.21 lodash: 4.17.21
quill-delta: 5.1.0 quill-delta: 5.1.0
'@signalapp/ringrtc@2.51.0': '@signalapp/ringrtc@2.52.0':
dependencies: dependencies:
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
tar: 6.2.1 tar: 6.2.1

View file

@ -128,6 +128,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
setGroupCallVideoRequest: action('set-group-call-video-request'), setGroupCallVideoRequest: action('set-group-call-video-request'),
setIsCallActive: action('set-is-call-active'), setIsCallActive: action('set-is-call-active'),
setLocalAudio: action('set-local-audio'), setLocalAudio: action('set-local-audio'),
setLocalAudioRemoteMuted: action('set-local-audio-remote-muted'),
setLocalPreviewContainer: action('set-local-preview-container'), setLocalPreviewContainer: action('set-local-preview-container'),
setLocalVideo: action('set-local-video'), setLocalVideo: action('set-local-video'),
setRendererCanvas: action('set-renderer-canvas'), setRendererCanvas: action('set-renderer-canvas'),

View file

@ -36,6 +36,7 @@ import type {
SendGroupCallReactionType, SendGroupCallReactionType,
SetGroupCallVideoRequestType, SetGroupCallVideoRequestType,
SetLocalAudioType, SetLocalAudioType,
SetMutedByType,
SetLocalVideoType, SetLocalVideoType,
SetRendererCanvasType, SetRendererCanvasType,
StartCallType, StartCallType,
@ -124,6 +125,7 @@ export type PropsType = {
setIsCallActive: (_: boolean) => void; setIsCallActive: (_: boolean) => void;
setLocalAudio: SetLocalAudioType; setLocalAudio: SetLocalAudioType;
setLocalVideo: SetLocalVideoType; setLocalVideo: SetLocalVideoType;
setLocalAudioRemoteMuted: SetMutedByType;
setLocalPreviewContainer: (container: HTMLDivElement | null) => void; setLocalPreviewContainer: (container: HTMLDivElement | null) => void;
setOutgoingRing: (_: boolean) => void; setOutgoingRing: (_: boolean) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
@ -188,6 +190,7 @@ function ActiveCallManager({
sendGroupCallReaction, sendGroupCallReaction,
setGroupCallVideoRequest, setGroupCallVideoRequest,
setLocalAudio, setLocalAudio,
setLocalAudioRemoteMuted,
setLocalPreviewContainer, setLocalPreviewContainer,
setLocalVideo, setLocalVideo,
setRendererCanvas, setRendererCanvas,
@ -475,6 +478,7 @@ function ActiveCallManager({
setLocalPreviewContainer={setLocalPreviewContainer} setLocalPreviewContainer={setLocalPreviewContainer}
setRendererCanvas={setRendererCanvas} setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio} setLocalAudio={setLocalAudio}
setLocalAudioRemoteMuted={setLocalAudioRemoteMuted}
setLocalVideo={setLocalVideo} setLocalVideo={setLocalVideo}
stickyControls={showParticipantsList} stickyControls={showParticipantsList}
switchToPresentationView={switchToPresentationView} switchToPresentationView={switchToPresentationView}
@ -567,6 +571,7 @@ export function CallManager({
setGroupCallVideoRequest, setGroupCallVideoRequest,
setIsCallActive, setIsCallActive,
setLocalAudio, setLocalAudio,
setLocalAudioRemoteMuted,
setLocalPreviewContainer, setLocalPreviewContainer,
setLocalVideo, setLocalVideo,
setOutgoingRing, setOutgoingRing,
@ -659,6 +664,7 @@ export function CallManager({
sendGroupCallReaction={sendGroupCallReaction} sendGroupCallReaction={sendGroupCallReaction}
setGroupCallVideoRequest={setGroupCallVideoRequest} setGroupCallVideoRequest={setGroupCallVideoRequest}
setLocalAudio={setLocalAudio} setLocalAudio={setLocalAudio}
setLocalAudioRemoteMuted={setLocalAudioRemoteMuted}
setLocalPreviewContainer={setLocalPreviewContainer} setLocalPreviewContainer={setLocalPreviewContainer}
setLocalVideo={setLocalVideo} setLocalVideo={setLocalVideo}
setOutgoingRing={setOutgoingRing} setOutgoingRing={setOutgoingRing}

View file

@ -10,6 +10,7 @@ import type {
ActiveCallReactionsType, ActiveCallReactionsType,
ActiveGroupCallType, ActiveGroupCallType,
GroupCallRemoteParticipantType, GroupCallRemoteParticipantType,
ObservedRemoteMuteType,
} from '../types/Calling'; } from '../types/Calling';
import { import {
CallViewMode, CallViewMode,
@ -19,6 +20,7 @@ import {
} from '../types/Calling'; } from '../types/Calling';
import { CallMode } from '../types/CallDisposition'; import { CallMode } from '../types/CallDisposition';
import { generateAci } from '../types/ServiceId'; import { generateAci } from '../types/ServiceId';
import type { AciString } from '../types/ServiceId';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import { AvatarColors } from '../types/Colors'; import { AvatarColors } from '../types/Colors';
import type { PropsType } from './CallScreen'; import type { PropsType } from './CallScreen';
@ -47,6 +49,7 @@ const conversation = getDefaultConversation({
name: 'Rick Sanchez', name: 'Rick Sanchez',
phoneNumber: '3051234567', phoneNumber: '3051234567',
profileName: 'Rick Sanchez', profileName: 'Rick Sanchez',
isMe: true,
}); });
type OverridePropsBase = { type OverridePropsBase = {
@ -56,6 +59,7 @@ type OverridePropsBase = {
viewMode?: CallViewMode; viewMode?: CallViewMode;
outgoingRing?: boolean; outgoingRing?: boolean;
reactions?: ActiveCallReactionsType; reactions?: ActiveCallReactionsType;
myAci?: AciString;
}; };
type DirectCallOverrideProps = OverridePropsBase & { type DirectCallOverrideProps = OverridePropsBase & {
@ -80,6 +84,9 @@ type GroupCallOverrideProps = OverridePropsBase & {
selfViewExpanded?: boolean; selfViewExpanded?: boolean;
suggestLowerHand?: boolean; suggestLowerHand?: boolean;
outgoingRing?: boolean; outgoingRing?: boolean;
localMutedBy?: number;
observedRemoteMute?: ObservedRemoteMuteType;
forceIndex0IsMe?: boolean;
}; };
const createActiveDirectCallProp = ( const createActiveDirectCallProp = (
@ -112,10 +119,16 @@ const getConversationsByDemuxId = (overrideProps: GroupCallOverrideProps) => {
const conversationsByDemuxId = new Map<number, ConversationType>( const conversationsByDemuxId = new Map<number, ConversationType>(
overrideProps.remoteParticipants?.map((participant, index) => [ overrideProps.remoteParticipants?.map((participant, index) => [
participant.demuxId, participant.demuxId,
getDefaultConversationWithServiceId({ getDefaultConversationWithServiceId(
isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, {
title: `Participant ${index + 1}`, isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1,
}), title: `Participant ${index + 1}`,
isMe: index === 0 && (overrideProps.forceIndex0IsMe ?? false),
},
index === 0 && (overrideProps.forceIndex0IsMe ?? false)
? conversation.serviceId
: undefined
),
]) ])
); );
conversationsByDemuxId.set(LOCAL_DEMUX_ID, conversation); conversationsByDemuxId.set(LOCAL_DEMUX_ID, conversation);
@ -164,6 +177,8 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
), ),
reactions: overrideProps.reactions || [], reactions: overrideProps.reactions || [],
suggestLowerHand: overrideProps.suggestLowerHand ?? false, suggestLowerHand: overrideProps.suggestLowerHand ?? false,
mutedBy: overrideProps.localMutedBy ?? undefined,
observedRemoteMute: overrideProps.observedRemoteMute ?? undefined,
}); });
const createActiveCallProp = ( const createActiveCallProp = (
@ -221,7 +236,7 @@ const createProps = (
name: 'Morty Smith', name: 'Morty Smith',
profileName: 'Morty Smith', profileName: 'Morty Smith',
title: 'Morty Smith', title: 'Morty Smith',
serviceId: generateAci(), serviceId: overrideProps.myAci ?? generateAci(),
}), }),
openSystemPreferencesAction: action('open-system-preferences-action'), openSystemPreferencesAction: action('open-system-preferences-action'),
renderEmojiPicker: () => <>EmojiPicker</>, renderEmojiPicker: () => <>EmojiPicker</>,
@ -231,6 +246,7 @@ const createProps = (
sendGroupCallReaction: action('send-group-call-reaction'), sendGroupCallReaction: action('send-group-call-reaction'),
setGroupCallVideoRequest: action('set-group-call-video-request'), setGroupCallVideoRequest: action('set-group-call-video-request'),
setLocalAudio: action('set-local-audio'), setLocalAudio: action('set-local-audio'),
setLocalAudioRemoteMuted: action('set-local-audio-remote-muted'),
setLocalPreviewContainer: action('set-local-preview-container'), setLocalPreviewContainer: action('set-local-preview-container'),
setLocalVideo: action('set-local-video'), setLocalVideo: action('set-local-video'),
setRendererCanvas: action('set-renderer-canvas'), setRendererCanvas: action('set-renderer-canvas'),
@ -997,3 +1013,131 @@ export function CallLinkUnknownContactMissingMediaKeys(): JSX.Element {
/> />
); );
} }
export function RemoteMuteYouRemoteMutedByOther(): JSX.Element {
// Should show you're muted by another
const [props, setProps] = React.useState(() =>
createProps({ callMode: CallMode.Group })
);
React.useEffect(() => {
setProps(
createProps({
callMode: CallMode.Group,
hasLocalAudio: false,
remoteParticipants: allRemoteParticipants,
localMutedBy: 3,
})
);
}, []);
return <CallScreen {...props} />;
}
export function RemoteMuteYouRemoteMutedBySelf(): JSX.Element {
// Should show you're muted by yourself
const [props, setProps] = React.useState(() =>
createProps({ callMode: CallMode.Group })
);
const myAci = conversation.serviceId as AciString;
React.useEffect(() => {
setProps(
createProps({
callMode: CallMode.Group,
remoteParticipants: [
{
aci: myAci,
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
isHandRaised: false,
mediaKeysReceived: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: false,
title: 'Note To Self',
serviceId: myAci,
isMe: true,
}),
},
],
localMutedBy: 0,
myAci,
forceIndex0IsMe: true,
})
);
}, [myAci]);
return <CallScreen {...props} />;
}
export function RemoteMuteObserveMuteYouSent(): JSX.Element {
// Should show you muted someone else
const [props, setProps] = React.useState(() =>
createProps({ callMode: CallMode.Group })
);
React.useEffect(() => {
setProps(
createProps({
callMode: CallMode.Group,
remoteParticipants: allRemoteParticipants,
observedRemoteMute: { source: LOCAL_DEMUX_ID, target: 0 },
})
);
}, []);
return <CallScreen {...props} />;
}
export function RemoteMuteObserveMuteOtherSent(): JSX.Element {
// Should show someone else muted a third person
const [props, setProps] = React.useState(() =>
createProps({ callMode: CallMode.Group })
);
React.useEffect(() => {
setProps(
createProps({
callMode: CallMode.Group,
remoteParticipants: allRemoteParticipants,
observedRemoteMute: { source: 3, target: 4 },
})
);
}, []);
return <CallScreen {...props} />;
}
export function RemoteMuteObserveIgnoreSelfMute(): JSX.Element {
// Should show nothing because the ACIs match
const [props, setProps] = React.useState(() =>
createProps({ callMode: CallMode.Group })
);
const myAci = conversation.serviceId as AciString;
React.useEffect(() => {
setProps(
createProps({
callMode: CallMode.Group,
remoteParticipants: [
{
aci: myAci,
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
isHandRaised: false,
mediaKeysReceived: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: false,
title: 'Note To Self',
serviceId: myAci,
isMe: true,
}),
},
],
observedRemoteMute: { source: LOCAL_DEMUX_ID, target: 0 },
myAci,
forceIndex0IsMe: true,
})
);
}, [myAci]);
return <CallScreen {...props} />;
}

View file

@ -15,6 +15,7 @@ import type {
SetLocalAudioType, SetLocalAudioType,
SetLocalVideoType, SetLocalVideoType,
SetRendererCanvasType, SetRendererCanvasType,
SetMutedByType,
} from '../state/ducks/calling'; } from '../state/ducks/calling';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { CallingHeader, getCallViewIconClassname } from './CallingHeader'; import { CallingHeader, getCallViewIconClassname } from './CallingHeader';
@ -135,6 +136,7 @@ export type PropsType = {
toggleSelfViewExpanded: () => void; toggleSelfViewExpanded: () => void;
toggleSettings: () => void; toggleSettings: () => void;
changeCallView: (mode: CallViewMode) => void; changeCallView: (mode: CallViewMode) => void;
setLocalAudioRemoteMuted: SetMutedByType;
} & Pick<ReactionPickerProps, 'renderEmojiPicker'>; } & Pick<ReactionPickerProps, 'renderEmojiPicker'>;
export const isInSpeakerView = ( export const isInSpeakerView = (
@ -224,6 +226,7 @@ export function CallScreen({
toggleScreenRecordingPermissionsDialog, toggleScreenRecordingPermissionsDialog,
toggleSelfViewExpanded, toggleSelfViewExpanded,
toggleSettings, toggleSettings,
setLocalAudioRemoteMuted,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const { const {
conversation, conversation,
@ -980,7 +983,17 @@ export function CallScreen({
: false : false
} }
isHandRaised={localHandRaised} isHandRaised={localHandRaised}
mutedBy={
isGroupOrAdhocActiveCall(activeCall) ? activeCall.mutedBy : undefined
}
observedRemoteMute={
isGroupOrAdhocActiveCall(activeCall)
? activeCall.observedRemoteMute
: undefined
}
conversationsByDemuxId={conversationsByDemuxId}
i18n={i18n} i18n={i18n}
setLocalAudioRemoteMuted={setLocalAudioRemoteMuted}
/> />
{isCallLinkAdmin ? ( {isCallLinkAdmin ? (
<CallingPendingParticipants <CallingPendingParticipants

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import type { ActiveCallType } from '../types/Calling'; import type { ActiveCallType, ObservedRemoteMuteType } from '../types/Calling';
import { CallMode } from '../types/CallDisposition'; import { CallMode } from '../types/CallDisposition';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
@ -12,6 +12,7 @@ import { difference as setDifference } from '../util/setUtil';
import { isMoreRecentThan } from '../util/timestamp'; import { isMoreRecentThan } from '../util/timestamp';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import type { SetMutedByType } from '../state/ducks/calling';
type PropsType = { type PropsType = {
activeCall: ActiveCallType; activeCall: ActiveCallType;
@ -83,9 +84,11 @@ export function useScreenSharingStoppedToast({
function useMutedToast({ function useMutedToast({
hasLocalAudio, hasLocalAudio,
mutedBy,
i18n, i18n,
}: { }: {
hasLocalAudio: boolean; hasLocalAudio: boolean;
mutedBy: number | undefined;
i18n: LocalizerType; i18n: LocalizerType;
}): void { }): void {
const previousHasLocalAudio = usePrevious(hasLocalAudio, hasLocalAudio); const previousHasLocalAudio = usePrevious(hasLocalAudio, hasLocalAudio);
@ -95,7 +98,8 @@ function useMutedToast({
useEffect(() => { useEffect(() => {
if ( if (
previousHasLocalAudio !== undefined && previousHasLocalAudio !== undefined &&
hasLocalAudio !== previousHasLocalAudio hasLocalAudio !== previousHasLocalAudio &&
mutedBy === undefined // skip this if we were muted by someone
) { ) {
hideToast(MUTED_TOAST_KEY); hideToast(MUTED_TOAST_KEY);
showToast({ showToast({
@ -107,7 +111,14 @@ function useMutedToast({
dismissable: true, dismissable: true,
}); });
} }
}, [hasLocalAudio, previousHasLocalAudio, hideToast, showToast, i18n]); }, [
hasLocalAudio,
previousHasLocalAudio,
hideToast,
showToast,
mutedBy,
i18n,
]);
} }
function useOutgoingRingToast({ function useOutgoingRingToast({
@ -321,6 +332,143 @@ function useLowerHandSuggestionToast({
}, [isHandRaised, hideToast]); }, [isHandRaised, hideToast]);
} }
function useMutedByToast({
mutedBy,
setLocalAudioRemoteMuted,
conversationsByDemuxId,
i18n,
}: {
mutedBy: number | undefined;
setLocalAudioRemoteMuted?: SetMutedByType;
conversationsByDemuxId?: Map<number, ConversationType>;
i18n: LocalizerType;
}): void {
const previousMutedBy = usePrevious(mutedBy, mutedBy);
const { showToast, hideToast } = useCallingToasts();
const MUTED_BY_TOAST_KEY = 'MUTED_BY_TOAST_KEY';
useEffect(() => {
if (setLocalAudioRemoteMuted === undefined) {
return;
}
if (
mutedBy === undefined ||
// if it's undefined, likely we just received a remote mute request
// and hadn't had one before.
(previousMutedBy !== undefined && previousMutedBy === mutedBy) ||
conversationsByDemuxId === undefined
) {
return;
}
const otherConversation = conversationsByDemuxId.get(mutedBy);
const title = otherConversation?.title;
if (title === undefined) {
return;
}
setLocalAudioRemoteMuted({ mutedBy });
let content;
if (otherConversation?.isMe) {
content = i18n('icu:CallControls__YouMutedYourselfToast');
} else {
content = i18n('icu:CallControls__MutedBySomeoneToast', {
otherName: title,
});
}
hideToast(MUTED_BY_TOAST_KEY);
showToast({
key: MUTED_BY_TOAST_KEY,
content,
dismissable: true,
autoClose: true,
lifetime: 10 * SECOND,
});
}, [
mutedBy,
previousMutedBy,
conversationsByDemuxId,
i18n,
showToast,
hideToast,
MUTED_BY_TOAST_KEY,
setLocalAudioRemoteMuted,
]);
}
function useObservedRemoteMuteToast({
observedRemoteMute,
conversationsByDemuxId,
i18n,
}: {
observedRemoteMute: ObservedRemoteMuteType | undefined;
conversationsByDemuxId?: Map<number, ConversationType>;
i18n: LocalizerType;
}): void {
const { showToast, hideToast } = useCallingToasts();
const OBSERVED_REMOTE_MUTE_TOAST_KEY = 'OBSERVED_REMOTE_MUTE_TOAST_KEY';
const previousObservedRemoteMute = usePrevious(
observedRemoteMute,
observedRemoteMute
);
useEffect(() => {
if (
observedRemoteMute === undefined ||
(previousObservedRemoteMute !== undefined &&
previousObservedRemoteMute === observedRemoteMute) ||
conversationsByDemuxId === undefined
) {
return;
}
const sourceConversation = conversationsByDemuxId.get(
observedRemoteMute.source
);
const targetConversation = conversationsByDemuxId.get(
observedRemoteMute.target
);
if (sourceConversation?.serviceId === targetConversation?.serviceId) {
// Ignore self-mutes.
return;
}
const targetTitle = targetConversation?.title;
if (targetTitle === undefined) {
return;
}
let content;
if (sourceConversation?.isMe) {
content = i18n('icu:CallControls__YouMutedSomeoneToast', {
otherName: targetTitle,
});
} else {
const sourceTitle = sourceConversation?.title;
if (sourceTitle === undefined) {
return;
}
content = i18n('icu:CallControls__SomeoneMutedSomeoneToast', {
name: sourceTitle,
otherName: targetTitle,
});
}
hideToast(OBSERVED_REMOTE_MUTE_TOAST_KEY);
showToast({
key: OBSERVED_REMOTE_MUTE_TOAST_KEY,
content,
dismissable: true,
autoClose: true,
lifetime: 10 * SECOND,
});
}, [
observedRemoteMute,
previousObservedRemoteMute,
conversationsByDemuxId,
i18n,
showToast,
hideToast,
OBSERVED_REMOTE_MUTE_TOAST_KEY,
]);
}
type CallingButtonToastsType = { type CallingButtonToastsType = {
hasLocalAudio: boolean; hasLocalAudio: boolean;
outgoingRing: boolean | undefined; outgoingRing: boolean | undefined;
@ -331,7 +479,11 @@ type CallingButtonToastsType = {
suggestLowerHand?: boolean; suggestLowerHand?: boolean;
isHandRaised?: boolean; isHandRaised?: boolean;
handleLowerHand?: () => void; handleLowerHand?: () => void;
mutedBy?: number;
observedRemoteMute?: ObservedRemoteMuteType;
conversationsByDemuxId?: Map<number, ConversationType>;
i18n: LocalizerType; i18n: LocalizerType;
setLocalAudioRemoteMuted?: SetMutedByType;
}; };
export function CallingButtonToastsContainer( export function CallingButtonToastsContainer(
@ -361,8 +513,12 @@ function CallingButtonToasts({
handleLowerHand, handleLowerHand,
isHandRaised, isHandRaised,
i18n, i18n,
mutedBy,
observedRemoteMute,
conversationsByDemuxId,
setLocalAudioRemoteMuted,
}: CallingButtonToastsType) { }: CallingButtonToastsType) {
useMutedToast({ hasLocalAudio, i18n }); useMutedToast({ hasLocalAudio, mutedBy, i18n });
useOutgoingRingToast({ outgoingRing, i18n }); useOutgoingRingToast({ outgoingRing, i18n });
useRaisedHandsToast({ raisedHands, renderRaisedHandsToast }); useRaisedHandsToast({ raisedHands, renderRaisedHandsToast });
useLowerHandSuggestionToast({ useLowerHandSuggestionToast({
@ -371,6 +527,17 @@ function CallingButtonToasts({
handleLowerHand, handleLowerHand,
isHandRaised, isHandRaised,
}); });
useMutedByToast({
mutedBy,
setLocalAudioRemoteMuted,
conversationsByDemuxId,
i18n,
});
useObservedRemoteMuteToast({
observedRemoteMute,
conversationsByDemuxId,
i18n,
});
return null; return null;
} }

View file

@ -221,6 +221,9 @@ type CallingReduxInterface = Pick<
| 'startCallLinkLobbyByRoomId' | 'startCallLinkLobbyByRoomId'
| 'peekNotConnectedGroupCall' | 'peekNotConnectedGroupCall'
| 'setSuggestLowerHand' | 'setSuggestLowerHand'
| 'setLocalAudio'
| 'setMutedBy'
| 'onObservedRemoteMute'
> & { > & {
areAnyCallsActiveOrRinging(): boolean; areAnyCallsActiveOrRinging(): boolean;
}; };
@ -1637,6 +1640,21 @@ export class CallingClass {
); );
} }
}, },
onRemoteMute: (_groupCall: GroupCall, demuxId: number) => {
log.info('GroupCall#onRemoteMute');
this.#reduxInterface?.setMutedBy({ mutedBy: demuxId });
},
onObservedRemoteMute: (
_groupCall: GroupCall,
sourceDemuxId: number,
targetDemuxId: number
) => {
log.info('GroupCall#onObservedRemoteMute');
this.#reduxInterface?.onObservedRemoteMute({
source: sourceDemuxId,
target: targetDemuxId,
});
},
}; };
} }
@ -2251,6 +2269,20 @@ export class CallingClass {
} }
} }
setOutgoingAudioRemoteMuted(conversationId: string, source: number): void {
const call = getOwn(this.#callsLookup, conversationId);
if (!call) {
log.warn('Trying to remote mute outgoing audio for a non-existent call');
return;
}
if (call instanceof GroupCall) {
call.setOutgoingAudioMutedRemotely(source);
} else {
log.warn('Trying to remote mute outgoing audio on a 1:1 call');
}
}
async setOutgoingVideo( async setOutgoingVideo(
conversationId: string, conversationId: string,
enabled: boolean enabled: boolean

View file

@ -29,6 +29,7 @@ import type {
ChangeIODevicePayloadType, ChangeIODevicePayloadType,
GroupCallVideoRequest, GroupCallVideoRequest,
MediaDeviceSettings, MediaDeviceSettings,
ObservedRemoteMuteType,
PresentedSource, PresentedSource,
PresentableSource, PresentableSource,
} from '../../types/Calling'; } from '../../types/Calling';
@ -192,6 +193,8 @@ export type ActiveCallStateType = {
showNeedsScreenRecordingPermissionsWarning?: boolean; showNeedsScreenRecordingPermissionsWarning?: boolean;
showParticipantsList: boolean; showParticipantsList: boolean;
suggestLowerHand?: boolean; suggestLowerHand?: boolean;
mutedBy?: number;
observedRemoteMute?: ObservedRemoteMuteType;
reactions?: ActiveCallReactionsType; reactions?: ActiveCallReactionsType;
}; };
export type WaitingCallStateType = ReadonlyDeep<{ export type WaitingCallStateType = ReadonlyDeep<{
@ -370,6 +373,18 @@ export type SetLocalVideoType = (
}> }>
) => void; ) => void;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type SetMutedByType = (
payload: ReadonlyDeep<{
mutedBy: number;
}>
) => void;
export type ObservedRemoteMuteDucksType = ReadonlyDeep<{
source: number;
target: number;
}>;
export type SetGroupCallVideoRequestType = ReadonlyDeep<{ export type SetGroupCallVideoRequestType = ReadonlyDeep<{
conversationId: string; conversationId: string;
resolutions: Array<GroupCallVideoRequest>; resolutions: Array<GroupCallVideoRequest>;
@ -658,6 +673,8 @@ const SELECT_PRESENTING_SOURCE = 'calling/SELECT_PRESENTING_SOURCE';
const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION'; const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION';
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED'; const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED'; const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
const SET_MUTED_BY = 'calling/SET_MUTED_BY';
const OBSERVED_REMOTE_MUTE = 'calling/OBSERVED_REMOTE_MUTE';
const SET_OUTGOING_RING = 'calling/SET_OUTGOING_RING'; const SET_OUTGOING_RING = 'calling/SET_OUTGOING_RING';
const SET_PRESENTING = 'calling/SET_PRESENTING'; const SET_PRESENTING = 'calling/SET_PRESENTING';
const SET_PRESENTING_SOURCES = 'calling/SET_PRESENTING_SOURCES'; const SET_PRESENTING_SOURCES = 'calling/SET_PRESENTING_SOURCES';
@ -915,6 +932,16 @@ type SetLocalVideoFulfilledActionType = ReadonlyDeep<{
payload: Parameters<SetLocalVideoType>[0]; payload: Parameters<SetLocalVideoType>[0];
}>; }>;
type SetMutedByActionType = ReadonlyDeep<{
type: 'calling/SET_MUTED_BY';
payload: Parameters<SetMutedByType>[0];
}>;
type ObservedRemoteMuteActionType = ReadonlyDeep<{
type: 'calling/OBSERVED_REMOTE_MUTE';
payload: ObservedRemoteMuteDucksType;
}>;
type SetPresentingFulfilledActionType = ReadonlyDeep<{ type SetPresentingFulfilledActionType = ReadonlyDeep<{
type: 'calling/SET_PRESENTING'; type: 'calling/SET_PRESENTING';
payload?: PresentedSource; payload?: PresentedSource;
@ -1019,6 +1046,8 @@ export type CallingActionType =
| SetCapturerBatonActionType | SetCapturerBatonActionType
| SetLocalAudioActionType | SetLocalAudioActionType
| SetLocalVideoFulfilledActionType | SetLocalVideoFulfilledActionType
| SetMutedByActionType
| ObservedRemoteMuteActionType
| SetPresentingSourcesActionType | SetPresentingSourcesActionType
| SetOutgoingRingActionType | SetOutgoingRingActionType
| StartDirectCallActionType | StartDirectCallActionType
@ -1929,6 +1958,28 @@ function setLocalAudio(
}; };
} }
function setLocalAudioRemoteMuted(
payload: Parameters<SetMutedByType>[0]
): ThunkAction<void, RootStateType, unknown, SetLocalAudioActionType> {
return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall) {
log.warn('Trying to set local audio when no call is active');
return;
}
calling.setOutgoingAudioRemoteMuted(
activeCall.conversationId,
payload?.mutedBy
);
dispatch({
type: SET_LOCAL_AUDIO_FULFILLED,
payload: { enabled: false },
});
};
}
function setLocalVideo( function setLocalVideo(
payload: Parameters<SetLocalVideoType>[0] payload: Parameters<SetLocalVideoType>[0]
): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> { ): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> {
@ -1967,6 +2018,40 @@ function setLocalVideo(
}; };
} }
function setMutedBy(
payload: Parameters<SetMutedByType>[0]
): ThunkAction<void, RootStateType, unknown, SetMutedByActionType> {
return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall) {
log.warn('Trying to set muted by when no call is active');
return;
}
dispatch({
type: SET_MUTED_BY,
payload,
});
};
}
function onObservedRemoteMute(
payload: ObservedRemoteMuteDucksType
): ThunkAction<void, RootStateType, unknown, ObservedRemoteMuteActionType> {
return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall) {
log.warn('Trying to record remote mute when no call is active');
return;
}
dispatch({
type: OBSERVED_REMOTE_MUTE,
payload,
});
};
}
function setGroupCallVideoRequest( function setGroupCallVideoRequest(
payload: SetGroupCallVideoRequestType payload: SetGroupCallVideoRequestType
): ThunkAction<void, RootStateType, unknown, never> { ): ThunkAction<void, RootStateType, unknown, never> {
@ -2765,6 +2850,7 @@ export const actions = {
handleCallLinkDelete, handleCallLinkDelete,
joinedAdhocCall, joinedAdhocCall,
leaveCurrentCallAndStartCallingLobby, leaveCurrentCallAndStartCallingLobby,
onObservedRemoteMute,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
openSystemPreferencesAction, openSystemPreferencesAction,
@ -2788,6 +2874,8 @@ export const actions = {
setIsCallActive, setIsCallActive,
setLocalAudio, setLocalAudio,
setLocalVideo, setLocalVideo,
setLocalAudioRemoteMuted,
setMutedBy,
setOutgoingRing, setOutgoingRing,
setRendererCanvas, setRendererCanvas,
setSuggestLowerHand, setSuggestLowerHand,
@ -3995,11 +4083,16 @@ export function reducer(
return state; return state;
} }
const newMutedBy = action.payload?.enabled
? undefined
: state.activeCallState.mutedBy;
return { return {
...state, ...state,
activeCallState: { activeCallState: {
...state.activeCallState, ...state.activeCallState,
hasLocalAudio: Boolean(action.payload?.enabled), hasLocalAudio: Boolean(action.payload?.enabled),
mutedBy: newMutedBy,
}, },
}; };
} }
@ -4019,6 +4112,47 @@ export function reducer(
}; };
} }
if (action.type === SET_MUTED_BY) {
const { mutedBy } = action.payload;
const { activeCallState } = state;
if (activeCallState?.state !== 'Active') {
log.warn('Cannot set muted by with no active call');
return state;
}
const newMutedBy = activeCallState.hasLocalAudio ? mutedBy : undefined;
return {
...state,
activeCallState: {
...activeCallState,
mutedBy: newMutedBy,
},
};
}
if (action.type === OBSERVED_REMOTE_MUTE) {
const { source, target } = action.payload;
const { activeCallState } = state;
if (activeCallState?.state !== 'Active') {
log.warn('Cannot observe muted by with no active call');
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
observedRemoteMute: {
source,
target,
},
},
};
}
if (action.type === CHANGE_IO_DEVICE_FULFILLED) { if (action.type === CHANGE_IO_DEVICE_FULFILLED) {
const { selectedDevice } = action.payload; const { selectedDevice } = action.payload;
const nextState = Object.create(null); const nextState = Object.create(null);

View file

@ -338,6 +338,8 @@ const mapStateToActiveCallProp = (
remoteParticipants, remoteParticipants,
remoteAudioLevels: call.remoteAudioLevels || new Map<number, number>(), remoteAudioLevels: call.remoteAudioLevels || new Map<number, number>(),
suggestLowerHand: Boolean(activeCallState.suggestLowerHand), suggestLowerHand: Boolean(activeCallState.suggestLowerHand),
mutedBy: activeCallState.mutedBy,
observedRemoteMute: activeCallState.observedRemoteMute,
} satisfies ActiveGroupCallType; } satisfies ActiveGroupCallType;
} }
default: default:
@ -460,6 +462,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
setGroupCallVideoRequest, setGroupCallVideoRequest,
setIsCallActive, setIsCallActive,
setLocalAudio, setLocalAudio,
setLocalAudioRemoteMuted,
setLocalVideo, setLocalVideo,
setOutgoingRing, setOutgoingRing,
setRendererCanvas, setRendererCanvas,
@ -519,6 +522,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
setGroupCallVideoRequest={setGroupCallVideoRequest} setGroupCallVideoRequest={setGroupCallVideoRequest}
setIsCallActive={setIsCallActive} setIsCallActive={setIsCallActive}
setLocalAudio={setLocalAudio} setLocalAudio={setLocalAudio}
setLocalAudioRemoteMuted={setLocalAudioRemoteMuted}
setLocalPreviewContainer={setLocalPreviewContainer} setLocalPreviewContainer={setLocalPreviewContainer}
setLocalVideo={setLocalVideo} setLocalVideo={setLocalVideo}
setOutgoingRing={setOutgoingRing} setOutgoingRing={setOutgoingRing}

View file

@ -84,6 +84,11 @@ export type ActiveDirectCallType = ActiveCallBaseType & {
remoteAudioLevel: number; remoteAudioLevel: number;
}; };
export type ObservedRemoteMuteType = {
source: number;
target: number;
};
export type ActiveGroupCallType = ActiveCallBaseType & { export type ActiveGroupCallType = ActiveCallBaseType & {
callMode: CallMode.Group | CallMode.Adhoc; callMode: CallMode.Group | CallMode.Adhoc;
connectionState: GroupCallConnectionState; connectionState: GroupCallConnectionState;
@ -100,6 +105,8 @@ export type ActiveGroupCallType = ActiveCallBaseType & {
remoteParticipants: Array<GroupCallRemoteParticipantType>; remoteParticipants: Array<GroupCallRemoteParticipantType>;
remoteAudioLevels: Map<number, number>; remoteAudioLevels: Map<number, number>;
suggestLowerHand: boolean; suggestLowerHand: boolean;
mutedBy?: number;
observedRemoteMute?: ObservedRemoteMuteType;
}; };
export type ActiveCallType = ActiveDirectCallType | ActiveGroupCallType; export type ActiveCallType = ActiveDirectCallType | ActiveGroupCallType;