diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index a924388ead..41098dcb4d 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -62,6 +62,7 @@ import { KIND_CALL_TRANSFER } from "./components/views/dialogs/InviteDialogTypes import { OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogPayload"; import { findDMForUser } from './utils/dm/findDMForUser'; import { getJoinedNonFunctionalMembers } from './utils/room/getJoinedNonFunctionalMembers'; +import { localNotificationsAreSilenced } from './utils/notifications'; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; @@ -184,6 +185,11 @@ export default class LegacyCallHandler extends EventEmitter { } } + public isForcedSilent(): boolean { + const cli = MatrixClientPeg.get(); + return localNotificationsAreSilenced(cli); + } + public silenceCall(callId: string): void { this.silencedCalls.add(callId); this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls); @@ -194,13 +200,14 @@ export default class LegacyCallHandler extends EventEmitter { } public unSilenceCall(callId: string): void { + if (this.isForcedSilent) return; this.silencedCalls.delete(callId); this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls); this.play(AudioID.Ring); } public isCallSilenced(callId: string): boolean { - return this.silencedCalls.has(callId); + return this.isForcedSilent() || this.silencedCalls.has(callId); } /** @@ -582,7 +589,7 @@ export default class LegacyCallHandler extends EventEmitter { action.value === "ring" )); - if (pushRuleEnabled && tweakSetToRing) { + if (pushRuleEnabled && tweakSetToRing && !this.isForcedSilent()) { this.play(AudioID.Ring); } else { this.silenceCall(call.callId); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0af4100c05..fa0b1690dc 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -806,13 +806,14 @@ "Video call started": "Video call started", "Video": "Video", "Close": "Close", + "Sound on": "Sound on", + "Silence call": "Silence call", + "Notifications silenced": "Notifications silenced", "Unknown caller": "Unknown caller", "Voice call": "Voice call", "Video call": "Video call", "Decline": "Decline", "Accept": "Accept", - "Sound on": "Sound on", - "Silence call": "Silence call", "Use app for a better experience": "Use app for a better experience", "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.": "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.", "Use app": "Use app", diff --git a/src/toasts/IncomingLegacyCallToast.tsx b/src/toasts/IncomingLegacyCallToast.tsx index ee640411ed..fec3fae1e9 100644 --- a/src/toasts/IncomingLegacyCallToast.tsx +++ b/src/toasts/IncomingLegacyCallToast.tsx @@ -85,6 +85,12 @@ export default class IncomingLegacyCallToast extends React.Component ; } diff --git a/test/LegacyCallHandler-test.ts b/test/LegacyCallHandler-test.ts index 8743c4cdf6..2fd774ae50 100644 --- a/test/LegacyCallHandler-test.ts +++ b/test/LegacyCallHandler-test.ts @@ -14,10 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IProtocol } from 'matrix-js-sdk/src/matrix'; -import { CallEvent, CallState, CallType } from 'matrix-js-sdk/src/webrtc/call'; +import { + IProtocol, + LOCAL_NOTIFICATION_SETTINGS_PREFIX, + MatrixEvent, + PushRuleKind, + RuleId, + TweakName, +} from 'matrix-js-sdk/src/matrix'; +import { CallEvent, CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import EventEmitter from 'events'; import { mocked } from 'jest-mock'; +import { CallEventHandlerEvent } from 'matrix-js-sdk/src/webrtc/callEventHandler'; import LegacyCallHandler, { LegacyCallHandlerEvent, PROTOCOL_PSTN, PROTOCOL_PSTN_PREFIXED, PROTOCOL_SIP_NATIVE, PROTOCOL_SIP_VIRTUAL, @@ -28,6 +36,8 @@ import DMRoomMap from '../src/utils/DMRoomMap'; import SdkConfig from '../src/SdkConfig'; import { Action } from "../src/dispatcher/actions"; import { getFunctionalMembers } from "../src/utils/room/getFunctionalMembers"; +import SettingsStore from '../src/settings/SettingsStore'; +import { UIFeature } from '../src/settings/UIFeature'; jest.mock("../src/utils/room/getFunctionalMembers", () => ({ getFunctionalMembers: jest.fn(), @@ -126,6 +136,7 @@ describe('LegacyCallHandler', () => { // what addresses the app has looked up via pstn and native lookup let pstnLookup: string; let nativeLookup: string; + const deviceId = 'my-device'; beforeEach(async () => { stubClient(); @@ -136,6 +147,7 @@ describe('LegacyCallHandler', () => { fakeCall = new FakeCall(roomId); return fakeCall; }; + MatrixClientPeg.get().deviceId = deviceId; MatrixClientPeg.get().getThirdpartyProtocols = () => { return Promise.resolve({ @@ -426,4 +438,137 @@ describe('LegacyCallHandler without third party protocols', () => { // but it should appear to the user to be in thw native room for Bob expect(callHandler.roomIdForCall(fakeCall)).toEqual(NATIVE_ROOM_ALICE); }); + + describe('incoming calls', () => { + const roomId = 'test-room-id'; + + const mockAudioElement = { + play: jest.fn(), + pause: jest.fn(), + } as unknown as HTMLMediaElement; + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(SettingsStore, 'getValue').mockImplementation(setting => + setting === UIFeature.Voip); + + jest.spyOn(MatrixClientPeg.get(), 'supportsVoip').mockReturnValue(true); + + MatrixClientPeg.get().isFallbackICEServerAllowed = jest.fn(); + MatrixClientPeg.get().prepareToEncrypt = jest.fn(); + + MatrixClientPeg.get().pushRules = { + global: { + [PushRuleKind.Override]: [{ + rule_id: RuleId.IncomingCall, + default: false, + enabled: true, + actions: [ + { + set_tweak: TweakName.Sound, + value: 'ring', + }, + ] + , + }], + }, + }; + + jest.spyOn(document, 'getElementById').mockReturnValue(mockAudioElement); + + // silence local notifications by default + jest.spyOn(MatrixClientPeg.get(), 'getAccountData').mockImplementation((eventType) => { + if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { + return new MatrixEvent({ + type: eventType, + content: { + is_silenced: true, + }, + }); + } + }); + }); + + it('listens for incoming call events when voip is enabled', () => { + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + + cli.emit(CallEventHandlerEvent.Incoming, call); + + // call added to call map + expect(callHandler.getCallForRoom(roomId)).toEqual(call); + }); + + it('rings when incoming call state is ringing and notifications set to ring', () => { + // remove local notification silencing mock for this test + jest.spyOn(MatrixClientPeg.get(), 'getAccountData').mockReturnValue(undefined); + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + + cli.emit(CallEventHandlerEvent.Incoming, call); + + // call added to call map + expect(callHandler.getCallForRoom(roomId)).toEqual(call); + call.emit(CallEvent.State, CallState.Ringing, CallState.Connected); + + // ringer audio element started + expect(mockAudioElement.play).toHaveBeenCalled(); + }); + + it('does not ring when incoming call state is ringing but local notifications are silenced', () => { + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + + cli.emit(CallEventHandlerEvent.Incoming, call); + + // call added to call map + expect(callHandler.getCallForRoom(roomId)).toEqual(call); + call.emit(CallEvent.State, CallState.Ringing, CallState.Connected); + + // ringer audio element started + expect(mockAudioElement.play).not.toHaveBeenCalled(); + expect(callHandler.isCallSilenced(call.callId)).toEqual(true); + }); + + it('should force calls to silent when local notifications are silenced', async () => { + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + + cli.emit(CallEventHandlerEvent.Incoming, call); + + expect(callHandler.isForcedSilent()).toEqual(true); + expect(callHandler.isCallSilenced(call.callId)).toEqual(true); + }); + + it('does not unsilence calls when local notifications are silenced', async () => { + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + const callHandlerEmitSpy = jest.spyOn(callHandler, 'emit'); + + cli.emit(CallEventHandlerEvent.Incoming, call); + // reset emit call count + callHandlerEmitSpy.mockClear(); + + callHandler.unSilenceCall(call.callId); + expect(callHandlerEmitSpy).not.toHaveBeenCalled(); + // call still silenced + expect(callHandler.isCallSilenced(call.callId)).toEqual(true); + // ringer not played + expect(mockAudioElement.play).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/components/views/settings/DevicesPanel-test.tsx b/test/components/views/settings/DevicesPanel-test.tsx index ef9801adac..a7baf139af 100644 --- a/test/components/views/settings/DevicesPanel-test.tsx +++ b/test/components/views/settings/DevicesPanel-test.tsx @@ -197,7 +197,7 @@ describe('', () => { await flushPromises(); // modal rendering has some weird sleeps - await sleep(10); + await sleep(20); // close the modal without submission act(() => { diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index 6478743458..d3274c589a 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -78,6 +78,7 @@ export const mockClientMethodsUser = (userId = '@alice:domain') => ({ getThreePids: jest.fn().mockResolvedValue({ threepids: [] }), getAccessToken: jest.fn(), getDeviceId: jest.fn(), + getAccountData: jest.fn(), }); /** @@ -103,6 +104,7 @@ export const mockClientMethodsServer = (): Partial', () => { + const userId = '@alice:server.org'; + const deviceId = 'my-device'; + + jest.spyOn(DMRoomMap, 'shared').mockReturnValue({ + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap); + + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + ...mockClientMethodsServer(), + getRoom: jest.fn(), + }); + const mockRoom = new Room('!room:server.org', mockClient, userId); + mockClient.deviceId = deviceId; + + const call = new MatrixCall({ client: mockClient }); + const defaultProps = { + call, + }; + const getComponent = (props = {}) => ; + + beforeEach(() => { + jest.clearAllMocks(); + mockClient.getAccountData.mockReturnValue(undefined); + mockClient.getRoom.mockReturnValue(mockRoom); + }); + + it('renders when silence button when call is not silenced', () => { + const { getByLabelText } = render(getComponent()); + expect(getByLabelText('Silence call')).toMatchSnapshot(); + }); + + it('renders sound on button when call is silenced', () => { + LegacyCallHandler.instance.silenceCall(call.callId); + const { getByLabelText } = render(getComponent()); + expect(getByLabelText('Sound on')).toMatchSnapshot(); + }); + + it('renders disabled silenced button when call is forced to silent', () => { + // silence local notifications -> force call ringer to silent + mockClient.getAccountData.mockImplementation((eventType) => { + if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { + return new MatrixEvent({ + type: eventType, + content: { + is_silenced: true, + }, + }); + } + }); + const { getByLabelText } = render(getComponent()); + expect(getByLabelText('Notifications silenced')).toMatchSnapshot(); + }); +}); diff --git a/test/toasts/__snapshots__/IncomingLegacyCallToast-test.tsx.snap b/test/toasts/__snapshots__/IncomingLegacyCallToast-test.tsx.snap new file mode 100644 index 0000000000..55252462bd --- /dev/null +++ b/test/toasts/__snapshots__/IncomingLegacyCallToast-test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders disabled silenced button when call is forced to silent 1`] = ` +
+`; + +exports[` renders sound on button when call is silenced 1`] = ` +
+`; + +exports[` renders when silence button when call is not silenced 1`] = ` +
+`;