diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index b6468fccaa2..39114812aed 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -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 diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 40e571d494c..10fe79e63a5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4164,6 +4164,22 @@ "messageformat": "Mic on", "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": { "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." diff --git a/package.json b/package.json index 47b92863d83..c3c49739acc 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "@react-types/shared": "3.27.0", "@signalapp/libsignal-client": "0.70.0", "@signalapp/quill-cjs": "2.1.2", - "@signalapp/ringrtc": "2.51.0", + "@signalapp/ringrtc": "2.52.0", "@signalapp/sqlcipher": "2.0.1", "@tanstack/react-virtual": "3.11.2", "@types/dom-mediacapture-transform": "0.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ddf3725170..13707449054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,8 +132,8 @@ importers: specifier: 2.1.2 version: 2.1.2 '@signalapp/ringrtc': - specifier: 2.51.0 - version: 2.51.0 + specifier: 2.52.0 + version: 2.52.0 '@signalapp/sqlcipher': specifier: 2.0.1 version: 2.0.1 @@ -2550,8 +2550,8 @@ packages: resolution: {integrity: sha512-y2sgqdivlrG41J4Zvt/82xtH/PZjDlgItqlD2g/Cv3ZbjlR6cGhTNXbfNygCJB8nXj+C7I28pjt1Zm3k0pv2mg==} engines: {npm: '>=8.2.3'} - '@signalapp/ringrtc@2.51.0': - resolution: {integrity: sha512-p0S7JLReO9NjfxB3Er3V6eydNB4IUfrIIimtlD7E9CerUWtejxvPNqEPxfKTCL/vVde/pqsIn0Qw9LjysA84xA==} + '@signalapp/ringrtc@2.52.0': + resolution: {integrity: sha512-s4mwnL4f6LBpDonIJ2TZKv1mD+zbAq6aPnPH3tlJZUhrdJj0GZtjTVY2i7Y6dmLG3zsho10a8hGtGjPAMyDmlw==} '@signalapp/sqlcipher@2.0.1': resolution: {integrity: sha512-7dSgNnf/hrGZfVSGlhVH39f7BDNNOO61tg6Xu/Fa38TCeZj6/U5YILKQavBArCtkahUvzGBV9QIyRr0zereU7A==} @@ -12072,7 +12072,7 @@ snapshots: lodash: 4.17.21 quill-delta: 5.1.0 - '@signalapp/ringrtc@2.51.0': + '@signalapp/ringrtc@2.52.0': dependencies: https-proxy-agent: 7.0.6 tar: 6.2.1 diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index dc54cbbb1de..caa448fd50f 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -128,6 +128,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ setGroupCallVideoRequest: action('set-group-call-video-request'), setIsCallActive: action('set-is-call-active'), setLocalAudio: action('set-local-audio'), + setLocalAudioRemoteMuted: action('set-local-audio-remote-muted'), setLocalPreviewContainer: action('set-local-preview-container'), setLocalVideo: action('set-local-video'), setRendererCanvas: action('set-renderer-canvas'), diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index bbb6a161ddb..5f59fc3d70a 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -36,6 +36,7 @@ import type { SendGroupCallReactionType, SetGroupCallVideoRequestType, SetLocalAudioType, + SetMutedByType, SetLocalVideoType, SetRendererCanvasType, StartCallType, @@ -124,6 +125,7 @@ export type PropsType = { setIsCallActive: (_: boolean) => void; setLocalAudio: SetLocalAudioType; setLocalVideo: SetLocalVideoType; + setLocalAudioRemoteMuted: SetMutedByType; setLocalPreviewContainer: (container: HTMLDivElement | null) => void; setOutgoingRing: (_: boolean) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; @@ -188,6 +190,7 @@ function ActiveCallManager({ sendGroupCallReaction, setGroupCallVideoRequest, setLocalAudio, + setLocalAudioRemoteMuted, setLocalPreviewContainer, setLocalVideo, setRendererCanvas, @@ -475,6 +478,7 @@ function ActiveCallManager({ setLocalPreviewContainer={setLocalPreviewContainer} setRendererCanvas={setRendererCanvas} setLocalAudio={setLocalAudio} + setLocalAudioRemoteMuted={setLocalAudioRemoteMuted} setLocalVideo={setLocalVideo} stickyControls={showParticipantsList} switchToPresentationView={switchToPresentationView} @@ -567,6 +571,7 @@ export function CallManager({ setGroupCallVideoRequest, setIsCallActive, setLocalAudio, + setLocalAudioRemoteMuted, setLocalPreviewContainer, setLocalVideo, setOutgoingRing, @@ -659,6 +664,7 @@ export function CallManager({ sendGroupCallReaction={sendGroupCallReaction} setGroupCallVideoRequest={setGroupCallVideoRequest} setLocalAudio={setLocalAudio} + setLocalAudioRemoteMuted={setLocalAudioRemoteMuted} setLocalPreviewContainer={setLocalPreviewContainer} setLocalVideo={setLocalVideo} setOutgoingRing={setOutgoingRing} diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 10e81d1feb6..b99119c6304 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -10,6 +10,7 @@ import type { ActiveCallReactionsType, ActiveGroupCallType, GroupCallRemoteParticipantType, + ObservedRemoteMuteType, } from '../types/Calling'; import { CallViewMode, @@ -19,6 +20,7 @@ import { } from '../types/Calling'; import { CallMode } from '../types/CallDisposition'; import { generateAci } from '../types/ServiceId'; +import type { AciString } from '../types/ServiceId'; import type { ConversationType } from '../state/ducks/conversations'; import { AvatarColors } from '../types/Colors'; import type { PropsType } from './CallScreen'; @@ -47,6 +49,7 @@ const conversation = getDefaultConversation({ name: 'Rick Sanchez', phoneNumber: '3051234567', profileName: 'Rick Sanchez', + isMe: true, }); type OverridePropsBase = { @@ -56,6 +59,7 @@ type OverridePropsBase = { viewMode?: CallViewMode; outgoingRing?: boolean; reactions?: ActiveCallReactionsType; + myAci?: AciString; }; type DirectCallOverrideProps = OverridePropsBase & { @@ -80,6 +84,9 @@ type GroupCallOverrideProps = OverridePropsBase & { selfViewExpanded?: boolean; suggestLowerHand?: boolean; outgoingRing?: boolean; + localMutedBy?: number; + observedRemoteMute?: ObservedRemoteMuteType; + forceIndex0IsMe?: boolean; }; const createActiveDirectCallProp = ( @@ -112,10 +119,16 @@ const getConversationsByDemuxId = (overrideProps: GroupCallOverrideProps) => { const conversationsByDemuxId = new Map( overrideProps.remoteParticipants?.map((participant, index) => [ participant.demuxId, - getDefaultConversationWithServiceId({ - isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, - title: `Participant ${index + 1}`, - }), + getDefaultConversationWithServiceId( + { + 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); @@ -164,6 +177,8 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({ ), reactions: overrideProps.reactions || [], suggestLowerHand: overrideProps.suggestLowerHand ?? false, + mutedBy: overrideProps.localMutedBy ?? undefined, + observedRemoteMute: overrideProps.observedRemoteMute ?? undefined, }); const createActiveCallProp = ( @@ -221,7 +236,7 @@ const createProps = ( name: 'Morty Smith', profileName: 'Morty Smith', title: 'Morty Smith', - serviceId: generateAci(), + serviceId: overrideProps.myAci ?? generateAci(), }), openSystemPreferencesAction: action('open-system-preferences-action'), renderEmojiPicker: () => <>EmojiPicker, @@ -231,6 +246,7 @@ const createProps = ( sendGroupCallReaction: action('send-group-call-reaction'), setGroupCallVideoRequest: action('set-group-call-video-request'), setLocalAudio: action('set-local-audio'), + setLocalAudioRemoteMuted: action('set-local-audio-remote-muted'), setLocalPreviewContainer: action('set-local-preview-container'), setLocalVideo: action('set-local-video'), 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 ; +} + +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 ; +} + +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 ; +} + +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 ; +} + +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 ; +} diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 244f78e6dc1..317ef2234eb 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -15,6 +15,7 @@ import type { SetLocalAudioType, SetLocalVideoType, SetRendererCanvasType, + SetMutedByType, } from '../state/ducks/calling'; import { Avatar, AvatarSize } from './Avatar'; import { CallingHeader, getCallViewIconClassname } from './CallingHeader'; @@ -135,6 +136,7 @@ export type PropsType = { toggleSelfViewExpanded: () => void; toggleSettings: () => void; changeCallView: (mode: CallViewMode) => void; + setLocalAudioRemoteMuted: SetMutedByType; } & Pick; export const isInSpeakerView = ( @@ -224,6 +226,7 @@ export function CallScreen({ toggleScreenRecordingPermissionsDialog, toggleSelfViewExpanded, toggleSettings, + setLocalAudioRemoteMuted, }: PropsType): JSX.Element { const { conversation, @@ -980,7 +983,17 @@ export function CallScreen({ : false } isHandRaised={localHandRaised} + mutedBy={ + isGroupOrAdhocActiveCall(activeCall) ? activeCall.mutedBy : undefined + } + observedRemoteMute={ + isGroupOrAdhocActiveCall(activeCall) + ? activeCall.observedRemoteMute + : undefined + } + conversationsByDemuxId={conversationsByDemuxId} i18n={i18n} + setLocalAudioRemoteMuted={setLocalAudioRemoteMuted} /> {isCallLinkAdmin ? ( { if ( previousHasLocalAudio !== undefined && - hasLocalAudio !== previousHasLocalAudio + hasLocalAudio !== previousHasLocalAudio && + mutedBy === undefined // skip this if we were muted by someone ) { hideToast(MUTED_TOAST_KEY); showToast({ @@ -107,7 +111,14 @@ function useMutedToast({ dismissable: true, }); } - }, [hasLocalAudio, previousHasLocalAudio, hideToast, showToast, i18n]); + }, [ + hasLocalAudio, + previousHasLocalAudio, + hideToast, + showToast, + mutedBy, + i18n, + ]); } function useOutgoingRingToast({ @@ -321,6 +332,143 @@ function useLowerHandSuggestionToast({ }, [isHandRaised, hideToast]); } +function useMutedByToast({ + mutedBy, + setLocalAudioRemoteMuted, + conversationsByDemuxId, + i18n, +}: { + mutedBy: number | undefined; + setLocalAudioRemoteMuted?: SetMutedByType; + conversationsByDemuxId?: Map; + 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; + 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 = { hasLocalAudio: boolean; outgoingRing: boolean | undefined; @@ -331,7 +479,11 @@ type CallingButtonToastsType = { suggestLowerHand?: boolean; isHandRaised?: boolean; handleLowerHand?: () => void; + mutedBy?: number; + observedRemoteMute?: ObservedRemoteMuteType; + conversationsByDemuxId?: Map; i18n: LocalizerType; + setLocalAudioRemoteMuted?: SetMutedByType; }; export function CallingButtonToastsContainer( @@ -361,8 +513,12 @@ function CallingButtonToasts({ handleLowerHand, isHandRaised, i18n, + mutedBy, + observedRemoteMute, + conversationsByDemuxId, + setLocalAudioRemoteMuted, }: CallingButtonToastsType) { - useMutedToast({ hasLocalAudio, i18n }); + useMutedToast({ hasLocalAudio, mutedBy, i18n }); useOutgoingRingToast({ outgoingRing, i18n }); useRaisedHandsToast({ raisedHands, renderRaisedHandsToast }); useLowerHandSuggestionToast({ @@ -371,6 +527,17 @@ function CallingButtonToasts({ handleLowerHand, isHandRaised, }); + useMutedByToast({ + mutedBy, + setLocalAudioRemoteMuted, + conversationsByDemuxId, + i18n, + }); + useObservedRemoteMuteToast({ + observedRemoteMute, + conversationsByDemuxId, + i18n, + }); return null; } diff --git a/ts/services/calling.ts b/ts/services/calling.ts index e346f1be867..dd57f5b1838 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -221,6 +221,9 @@ type CallingReduxInterface = Pick< | 'startCallLinkLobbyByRoomId' | 'peekNotConnectedGroupCall' | 'setSuggestLowerHand' + | 'setLocalAudio' + | 'setMutedBy' + | 'onObservedRemoteMute' > & { 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( conversationId: string, enabled: boolean diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 65b5e9b403d..de1a24ac9be 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -29,6 +29,7 @@ import type { ChangeIODevicePayloadType, GroupCallVideoRequest, MediaDeviceSettings, + ObservedRemoteMuteType, PresentedSource, PresentableSource, } from '../../types/Calling'; @@ -192,6 +193,8 @@ export type ActiveCallStateType = { showNeedsScreenRecordingPermissionsWarning?: boolean; showParticipantsList: boolean; suggestLowerHand?: boolean; + mutedBy?: number; + observedRemoteMute?: ObservedRemoteMuteType; reactions?: ActiveCallReactionsType; }; export type WaitingCallStateType = ReadonlyDeep<{ @@ -370,6 +373,18 @@ export type SetLocalVideoType = ( }> ) => 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<{ conversationId: string; resolutions: Array; @@ -658,6 +673,8 @@ const SELECT_PRESENTING_SOURCE = 'calling/SELECT_PRESENTING_SOURCE'; const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION'; const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_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_PRESENTING = 'calling/SET_PRESENTING'; const SET_PRESENTING_SOURCES = 'calling/SET_PRESENTING_SOURCES'; @@ -915,6 +932,16 @@ type SetLocalVideoFulfilledActionType = ReadonlyDeep<{ payload: Parameters[0]; }>; +type SetMutedByActionType = ReadonlyDeep<{ + type: 'calling/SET_MUTED_BY'; + payload: Parameters[0]; +}>; + +type ObservedRemoteMuteActionType = ReadonlyDeep<{ + type: 'calling/OBSERVED_REMOTE_MUTE'; + payload: ObservedRemoteMuteDucksType; +}>; + type SetPresentingFulfilledActionType = ReadonlyDeep<{ type: 'calling/SET_PRESENTING'; payload?: PresentedSource; @@ -1019,6 +1046,8 @@ export type CallingActionType = | SetCapturerBatonActionType | SetLocalAudioActionType | SetLocalVideoFulfilledActionType + | SetMutedByActionType + | ObservedRemoteMuteActionType | SetPresentingSourcesActionType | SetOutgoingRingActionType | StartDirectCallActionType @@ -1929,6 +1958,28 @@ function setLocalAudio( }; } +function setLocalAudioRemoteMuted( + payload: Parameters[0] +): ThunkAction { + 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( payload: Parameters[0] ): ThunkAction { @@ -1967,6 +2018,40 @@ function setLocalVideo( }; } +function setMutedBy( + payload: Parameters[0] +): ThunkAction { + 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 { + 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( payload: SetGroupCallVideoRequestType ): ThunkAction { @@ -2765,6 +2850,7 @@ export const actions = { handleCallLinkDelete, joinedAdhocCall, leaveCurrentCallAndStartCallingLobby, + onObservedRemoteMute, onOutgoingVideoCallInConversation, onOutgoingAudioCallInConversation, openSystemPreferencesAction, @@ -2788,6 +2874,8 @@ export const actions = { setIsCallActive, setLocalAudio, setLocalVideo, + setLocalAudioRemoteMuted, + setMutedBy, setOutgoingRing, setRendererCanvas, setSuggestLowerHand, @@ -3995,11 +4083,16 @@ export function reducer( return state; } + const newMutedBy = action.payload?.enabled + ? undefined + : state.activeCallState.mutedBy; + return { ...state, activeCallState: { ...state.activeCallState, 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) { const { selectedDevice } = action.payload; const nextState = Object.create(null); diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index dc24a50c154..c5f2222bd40 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -338,6 +338,8 @@ const mapStateToActiveCallProp = ( remoteParticipants, remoteAudioLevels: call.remoteAudioLevels || new Map(), suggestLowerHand: Boolean(activeCallState.suggestLowerHand), + mutedBy: activeCallState.mutedBy, + observedRemoteMute: activeCallState.observedRemoteMute, } satisfies ActiveGroupCallType; } default: @@ -460,6 +462,7 @@ export const SmartCallManager = memo(function SmartCallManager() { setGroupCallVideoRequest, setIsCallActive, setLocalAudio, + setLocalAudioRemoteMuted, setLocalVideo, setOutgoingRing, setRendererCanvas, @@ -519,6 +522,7 @@ export const SmartCallManager = memo(function SmartCallManager() { setGroupCallVideoRequest={setGroupCallVideoRequest} setIsCallActive={setIsCallActive} setLocalAudio={setLocalAudio} + setLocalAudioRemoteMuted={setLocalAudioRemoteMuted} setLocalPreviewContainer={setLocalPreviewContainer} setLocalVideo={setLocalVideo} setOutgoingRing={setOutgoingRing} diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index ddab91fe20c..0f055dc0d9d 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -84,6 +84,11 @@ export type ActiveDirectCallType = ActiveCallBaseType & { remoteAudioLevel: number; }; +export type ObservedRemoteMuteType = { + source: number; + target: number; +}; + export type ActiveGroupCallType = ActiveCallBaseType & { callMode: CallMode.Group | CallMode.Adhoc; connectionState: GroupCallConnectionState; @@ -100,6 +105,8 @@ export type ActiveGroupCallType = ActiveCallBaseType & { remoteParticipants: Array; remoteAudioLevels: Map; suggestLowerHand: boolean; + mutedBy?: number; + observedRemoteMute?: ObservedRemoteMuteType; }; export type ActiveCallType = ActiveDirectCallType | ActiveGroupCallType;