feat: Explicit answer/reject for inbound calls (#2504)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent b9f09b7c4e
commit f397596ed1
26 changed files with 503 additions and 362 deletions
@@ -0,0 +1,18 @@
import { expect, it } from 'vitest';
import { CallAnswerAction } from '../../../../src/card-controller/actions/actions/call-answer';
import { createCardAPI } from '../../../test-utils';
it('should handle call_answer action', async () => {
const api = createCardAPI();
const action = new CallAnswerAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'call_answer',
},
);
await action.execute(api);
expect(api.getCallManager().answer).toBeCalled();
});
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { CallAnswerAction } from '../../../src/card-controller/actions/actions/call-answer';
import { CallEndAction } from '../../../src/card-controller/actions/actions/call-end';
import { CallServiceAction } from '../../../src/card-controller/actions/actions/call-service';
import { CallStartAction } from '../../../src/card-controller/actions/actions/call-start';
@@ -88,6 +89,7 @@ describe('ActionFactory', () => {
describe('custom actions', () => {
it.each([
[{ advanced_camera_card_action: 'call_answer' as const }, CallAnswerAction],
[{ advanced_camera_card_action: 'call_end' as const }, CallEndAction],
[{ advanced_camera_card_action: 'call_start' as const }, CallStartAction],
[{ advanced_camera_card_action: 'camera_select' as const }, CameraSelectAction],
+131 -191
View File
@@ -104,12 +104,12 @@ describe('isActive', () => {
expect(manager.isActive()).toBe(true);
// The call runs on the parent camera's own stream, so callCameraID is
// absent.
// absent. Outbound calls are answered by construction.
expect(manager.getCall()).toEqual({
cameraID: 'camera.office',
previousView: expect.any(View),
inbound: false,
answered: false,
answered: true,
});
expect(manager.getCall()?.previousView?.view).toBe('live');
});
@@ -569,20 +569,7 @@ describe('inbound supersede policy', () => {
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
// Answer the call.
getConditionStateListener(api)({
old: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: true, forbidden: false },
},
change: { microphone: { connected: true, muted: false, forbidden: false } },
new: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: false, forbidden: false },
},
});
expect(manager.answer()).toBe(true);
expect(manager.getCall()?.answered).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe(
@@ -637,19 +624,7 @@ describe('inbound supersede policy', () => {
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
getConditionStateListener(api)({
old: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: true, forbidden: false },
},
change: { microphone: { connected: true, muted: false, forbidden: false } },
new: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: false, forbidden: false },
},
});
expect(manager.answer()).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
@@ -862,19 +837,7 @@ describe('endIf', () => {
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
getConditionStateListener(api)({
old: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: true, forbidden: false },
},
change: { microphone: { connected: true, muted: false, forbidden: false } },
new: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: false, forbidden: false },
},
});
expect(manager.answer()).toBe(true);
expect(manager.getCall()?.answered).toBe(true);
expect(manager.endIf({ answered: false })).toBe(false);
@@ -1242,15 +1205,12 @@ describe('inbound option', () => {
});
});
// Answered tracking: the first muted->unmuted microphone transition during an
// inbound call flips `answered` to true (once; later mute/unmute cycles do not
// flip it back) and stops the ringtone / cancels the unanswered timer.
describe('answered tracking', () => {
describe('answer', () => {
const inboundConfig = {
live: { controls: { call: { ringtone: { type: 'chime' as const } } } },
};
it('should not be answered immediately after an inbound start', async () => {
it('should default to unanswered for an inbound start', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
@@ -1263,7 +1223,40 @@ describe('answered tracking', () => {
expect(manager.getCall()?.answered).toBe(false);
});
it('should mark answered on a muted->unmuted microphone transition', async () => {
it('should default to answered for an outbound start', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
// Outbound calls are answered by construction -- the user initiated them.
expect(manager.getCall()?.answered).toBe(true);
});
it('should default to unanswered for inbound even if the mic is already un-muted', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneMuted: false,
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.getCall()?.answered).toBe(false);
expect(getRingtone().start).toBeCalled();
});
it('should no-op when no call is active', () => {
const api = createAPI();
const manager = new CallManager(api);
expect(manager.answer()).toBe(false);
});
it('should no-op when the call is already answered', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
@@ -1271,25 +1264,100 @@ describe('answered tracking', () => {
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.answer()).toBe(true);
vi.mocked(getRingtone().stop).mockClear();
vi.mocked(api.getCardElementManager().update).mockClear();
getConditionStateListener(api)({
old: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: true, forbidden: false },
},
change: { microphone: { connected: true, muted: false, forbidden: false } },
new: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: false, forbidden: false },
},
expect(manager.answer()).toBe(false);
expect(getRingtone().stop).not.toBeCalled();
expect(api.getCardElementManager().update).not.toBeCalled();
});
it('should mark answered and replace the session immutably', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
const before = manager.getCall();
expect(manager.answer()).toBe(true);
const after = manager.getCall();
expect(after?.answered).toBe(true);
// New object identity so Lit consumers re-render on the prop change.
expect(after).not.toBe(before);
expect(after?.cameraID).toBe(before?.cameraID);
expect(after?.inbound).toBe(before?.inbound);
});
it('should stop the ringtone and unanswered timer on answer', async () => {
vi.useFakeTimers();
try {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: {
live: {
controls: {
call: {
ringtone: { type: 'chime' as const },
unanswered_timeout_seconds: 60,
},
},
},
},
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(getRingtone().stop).mockClear();
expect(manager.answer()).toBe(true);
expect(getRingtone().stop).toBeCalled();
// Timer was armed and should now be cancelled: advancing past the
// timeout must not end the (now-answered) call.
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('should force a card re-render on answer', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(api.getCardElementManager().update).mockClear();
expect(manager.answer()).toBe(true);
// The card subtree depends on `getCall().answered`, which the manager
// mutates outside the view-manager epoch -- so `update()` is what drives
// the re-render through to the call-controls overlay.
expect(api.getCardElementManager().update).toBeCalled();
});
it('should not mark non-inbound (outbound) calls via answer (already answered)', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
// Outbound starts answered, so `answer()` is a no-op.
expect(manager.answer()).toBe(false);
expect(manager.getCall()?.answered).toBe(true);
});
it('should not flip answered back when the user re-mutes after answering', async () => {
it('should not flip answered on mic mute/unmute transitions', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
@@ -1299,6 +1367,7 @@ describe('answered tracking', () => {
expect(await manager.start({ inbound: true })).toBe(true);
const listener = getConditionStateListener(api);
// Mic mute -> unmute during the pre-answer ring: must NOT auto-answer.
listener({
old: {
camera: 'camera.office',
@@ -1312,124 +1381,7 @@ describe('answered tracking', () => {
microphone: { connected: true, muted: false, forbidden: false },
},
});
expect(manager.getCall()?.answered).toBe(true);
listener({
old: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: false, forbidden: false },
},
change: { microphone: { connected: true, muted: true, forbidden: false } },
new: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: true, forbidden: false },
},
});
expect(manager.getCall()?.answered).toBe(true);
});
it('should not mark non-inbound calls answered on un-mute', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
getConditionStateListener(api)({
old: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: true, forbidden: false },
},
change: { microphone: { connected: true, muted: false, forbidden: false } },
new: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: false, forbidden: false },
},
});
expect(manager.getCall()?.answered).toBe(false);
});
it('should stop the ringtone on answer', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(getRingtone().stop).mockClear();
getConditionStateListener(api)({
old: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: true, forbidden: false },
},
change: { microphone: { connected: true, muted: false, forbidden: false } },
new: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: false, forbidden: false },
},
});
expect(getRingtone().stop).toBeCalled();
});
it('should treat an inbound call as already-answered when the mic is already un-muted', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneMuted: false,
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.getCall()?.answered).toBe(true);
expect(getRingtone().start).not.toBeCalled();
});
it('should not arm the unanswered timer when the mic is already un-muted', async () => {
vi.useFakeTimers();
try {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneMuted: false,
config: {
live: { controls: { call: { unanswered_timeout_seconds: 60 } } },
},
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('should not mark a manual (non-inbound) call as answered even if the mic is un-muted', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneMuted: false,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
// `answered` only carries meaning alongside `inbound`, so for manual
// calls it stays at its default to make the intent explicit.
expect(manager.getCall()?.answered).toBe(false);
});
});
@@ -1572,19 +1524,7 @@ describe('unanswered timeout', () => {
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
getConditionStateListener(api)({
old: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: true, forbidden: false },
},
change: { microphone: { connected: true, muted: false, forbidden: false } },
new: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: false, forbidden: false },
},
});
expect(manager.answer()).toBe(true);
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(true);
@@ -294,8 +294,8 @@ describe('MicrophoneActionsController', () => {
});
});
describe('on call state change', () => {
it('should unmute on call start when call is a configured unmute condition', () => {
describe('on call answered state change', () => {
it('should unmute on call answer when call is a configured unmute condition', () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
@@ -303,13 +303,13 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: ['call' as const],
});
controller.setCallActive(false);
controller.setCallActive(true);
controller.setCallAnswered(false);
controller.setCallAnswered(true);
expect(microphoneManager.unmute).toBeCalledTimes(1);
});
it('should unmute when the call is already active on first notification', () => {
it('should unmute when the call is already answered on first notification', () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
@@ -317,10 +317,10 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: ['call' as const],
});
// `setCallActive(true)` is the first call-state signal, with no preceding
// `false` -- as for a live view that mounts while a call is already
// active. The initial state must not be swallowed as a baseline.
controller.setCallActive(true);
// `setCallAnswered(true)` is the first call-state signal, with no
// preceding `false` -- as for a live view that mounts while a call is
// already answered. The initial state must not be swallowed as a baseline.
controller.setCallAnswered(true);
expect(microphoneManager.unmute).toBeCalledTimes(1);
});
@@ -333,8 +333,8 @@ describe('MicrophoneActionsController', () => {
autoMuteConditions: ['call' as const],
});
controller.setCallActive(true);
controller.setCallActive(false);
controller.setCallAnswered(true);
controller.setCallAnswered(false);
expect(microphoneManager.mute).toBeCalledTimes(1);
});
@@ -348,13 +348,13 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: ['call' as const],
});
controller.setCallActive(false);
controller.setCallAnswered(false);
expect(microphoneManager.mute).not.toBeCalled();
expect(microphoneManager.unmute).not.toBeCalled();
});
it('should not act on call start when call is not a configured condition', () => {
it('should not act on call answer when call is not a configured condition', () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
@@ -362,8 +362,8 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: [],
});
controller.setCallActive(false);
controller.setCallActive(true);
controller.setCallAnswered(false);
controller.setCallAnswered(true);
expect(microphoneManager.unmute).not.toBeCalled();
});
@@ -712,22 +712,22 @@ describe('MediaActionsController', () => {
});
});
describe('should take action on call state changes', () => {
it('should unmute the target on call start', async () => {
describe('should take action on call answered state changes', () => {
it('should unmute the target on call answer', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoUnmuteConditions: ['call' as const],
playerSelector: 'video',
});
controller.setCallActive(false);
controller.setCallAnswered(false);
const children = createPlayerSlideNodes();
controller.setRoot(createParent({ children: children }));
await controller.setTarget(0, true);
controller.setCallActive(true);
controller.setCallAnswered(true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
@@ -741,14 +741,14 @@ describe('MediaActionsController', () => {
autoMuteConditions: ['call' as const],
playerSelector: 'video',
});
controller.setCallActive(true);
controller.setCallAnswered(true);
const children = createPlayerSlideNodes();
controller.setRoot(createParent({ children: children }));
await controller.setTarget(0, true);
controller.setCallActive(false);
controller.setCallAnswered(false);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute,
@@ -768,7 +768,7 @@ describe('MediaActionsController', () => {
controller.setRoot(createParent({ children: children }));
await controller.setTarget(0, true);
controller.setCallActive(false);
controller.setCallAnswered(false);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute,
@@ -782,20 +782,20 @@ describe('MediaActionsController', () => {
autoUnmuteConditions: [],
playerSelector: 'video',
});
controller.setCallActive(false);
controller.setCallAnswered(false);
const children = createPlayerSlideNodes();
controller.setRoot(createParent({ children: children }));
await controller.setTarget(0, true);
controller.setCallActive(true);
controller.setCallAnswered(true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
).not.toBeCalled();
});
it('should apply the call-start unmute when the target arrives after the call', async () => {
it('should apply the call-answer unmute when the target arrives after the call', async () => {
const controller = new MediaActionsController();
controller.setOptions({
@@ -806,9 +806,9 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.setRoot(createParent({ children: children }));
// The call becomes active before any target is selected: with no
// target, the unmute cannot be applied yet.
controller.setCallActive(true);
// The call is answered before any target is selected: with no target,
// the unmute cannot be applied yet.
controller.setCallAnswered(true);
await flushPromises();
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
@@ -821,7 +821,7 @@ describe('MediaActionsController', () => {
).toBeCalled();
});
it('should unmute when the call is already active on the first call-state signal', async () => {
it('should unmute when the call is already answered on the first call-state signal', async () => {
const controller = new MediaActionsController();
controller.setOptions({
@@ -833,24 +833,25 @@ describe('MediaActionsController', () => {
controller.setRoot(createParent({ children: children }));
await controller.setTarget(0, true);
// `setCallActive(true)` is the first call-state signal, with no preceding
// `false` -- as for a carousel that loads while a call is already
// active. The initial state must not be swallowed as a baseline.
controller.setCallActive(true);
// `setCallAnswered(true)` is the first call-state signal, with no
// preceding `false` -- as for a carousel that loads while an answered
// call is already active. The initial state must not be swallowed as
// a baseline.
controller.setCallAnswered(true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
).toBeCalled();
});
it('should defer the call-start unmute until the media player is ready', async () => {
it('should defer the call-answer unmute until the media player is ready', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoUnmuteConditions: ['call' as const],
playerSelector: 'video',
});
controller.setCallActive(false);
controller.setCallAnswered(false);
// A player whose media player controller is not ready on first request.
const mediaPlayerController = mock<MediaPlayerController>();
@@ -865,8 +866,8 @@ describe('MediaActionsController', () => {
controller.setRoot(createParent({ children: [child] }));
await controller.setTarget(0, true);
// The call starts while the player is still not ready: no unmute yet.
controller.setCallActive(true);
// The call is answered while the player is still not ready: no unmute yet.
controller.setCallAnswered(true);
await flushPromises();
expect(mediaPlayerController.unmute).not.toBeCalled();
+18
View File
@@ -3,6 +3,7 @@ import { mock } from 'vitest-mock-extended';
import { INTERNAL_CALLBACK_ACTION } from '../../src/config/schema/actions/custom/internal.js';
import { ActionConfig } from '../../src/config/schema/actions/types.js';
import {
createCallAnswerAction,
createCallEndAction,
createCallStartAction,
createCameraAction,
@@ -430,6 +431,23 @@ describe('createSubstreamOffAction', () => {
});
});
describe('createCallAnswerAction', () => {
it('should create call answer action', () => {
expect(createCallAnswerAction()).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'call_answer',
});
});
it('should create call answer action with a cardID', () => {
expect(createCallAnswerAction({ cardID: 'card_id' })).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'call_answer',
card_id: 'card_id',
});
});
});
describe('createCallEndAction', () => {
it('should create call end action', () => {
expect(createCallEndAction()).toEqual({