Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make GroupCall work better with widgets #2935

Merged
merged 2 commits into from
Dec 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions spec/test-utils/webrtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,9 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
public getRooms = jest.fn<Room[], []>().mockReturnValue([]);
public getRoom = jest.fn();

public supportsExperimentalThreads(): boolean { return true; }
public async decryptEventIfNeeded(): Promise<void> {}

public typed(): MatrixClient { return this as unknown as MatrixClient; }

public emitRoomState(event: MatrixEvent, state: RoomState): void {
Expand Down
179 changes: 167 additions & 12 deletions spec/unit/webrtc/groupCall.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
} from '../../../src';
import { RoomStateEvent } from "../../../src/models/room-state";
import { GroupCall, GroupCallEvent, GroupCallState } from "../../../src/webrtc/groupCall";
import { MatrixClient } from "../../../src/client";
import { IMyDevice, MatrixClient } from "../../../src/client";
import {
installWebRTCMocks,
MockCallFeed,
Expand Down Expand Up @@ -180,13 +180,13 @@ describe('Group Call', function() {

room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt);
});

it("does not initialize local call feed, if it already is", async () => {
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
membership: "join",
} as unknown as RoomMember;
});

it("does not initialize local call feed, if it already is", async () => {
await groupCall.initLocalCallFeed();
jest.spyOn(groupCall, "initLocalCallFeed");
await groupCall.enter();
Expand Down Expand Up @@ -216,10 +216,6 @@ describe('Group Call', function() {
});

it("sends member state event to room on enter", async () => {
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
} as unknown as RoomMember;

await groupCall.create();

try {
Expand Down Expand Up @@ -249,10 +245,6 @@ describe('Group Call', function() {
});

it("sends member state event to room on leave", async () => {
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
} as unknown as RoomMember;

await groupCall.create();
await groupCall.enter();
mockSendState.mockClear();
Expand All @@ -267,6 +259,18 @@ describe('Group Call', function() {
);
});

it("includes local device in participants when entered via another session", async () => {
const hasLocalParticipant = () => groupCall.participants.get(
room.getMember(mockClient.getUserId()!)!,
)?.has(mockClient.getDeviceId()!) ?? false;

expect(groupCall.enteredViaAnotherSession).toBe(false);
expect(hasLocalParticipant()).toBe(false);

groupCall.enteredViaAnotherSession = true;
expect(hasLocalParticipant()).toBe(true);
});

it("starts with mic unmuted in regular calls", async () => {
try {
await groupCall.create();
Expand Down Expand Up @@ -1270,4 +1274,155 @@ describe('Group Call', function() {
});
});
});

describe("cleaning member state", () => {
const bobWeb: IMyDevice = {
device_id: "bobweb",
last_seen_ts: 0,
};
const bobDesktop: IMyDevice = {
device_id: "bobdesktop",
last_seen_ts: 0,
};
const bobDesktopOffline: IMyDevice = {
device_id: "bobdesktopoffline",
last_seen_ts: 1000 * 60 * 60 * -2, // 2 hours ago
};
const bobDesktopNeverOnline: IMyDevice = {
device_id: "bobdesktopneveronline",
};

const mkContent = (devices: IMyDevice[]) => ({
"m.calls": [{
"m.call_id": groupCall.groupCallId,
"m.devices": devices.map(d => ({
device_id: d.device_id, session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10,
})),
}],
});

const expectDevices = (devices: IMyDevice[]) => expect(
room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)?.getContent(),
).toEqual({
"m.calls": [{
"m.call_id": groupCall.groupCallId,
"m.devices": devices.map(d => ({
device_id: d.device_id, session_id: "1", feeds: [], expires_ts: expect.any(Number),
})),
}],
});

let mockClient: MatrixClient;
let room: Room;
let groupCall: GroupCall;

beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});

afterAll(() => jest.useRealTimers());

beforeEach(async () => {
const typedMockClient = new MockCallMatrixClient(
FAKE_USER_ID_2, bobWeb.device_id, FAKE_SESSION_ID_2,
);
jest.spyOn(typedMockClient, "sendStateEvent").mockImplementation(
async (roomId, eventType, content, stateKey) => {
const eventId = `$${Math.random()}`;
if (roomId === room.roomId) {
room.addLiveEvents([new MatrixEvent({
event_id: eventId,
type: eventType,
room_id: roomId,
sender: FAKE_USER_ID_2,
content,
state_key: stateKey,
})]);
}
return { event_id: eventId };
},
);
mockClient = typedMockClient as unknown as MatrixClient;

room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_2);
room.getMember = jest.fn().mockImplementation((userId) => ({ userId }));

groupCall = new GroupCall(
mockClient,
room,
GroupCallType.Video,
false,
GroupCallIntent.Prompt,
FAKE_CONF_ID,
);
await groupCall.create();

mockClient.getDevices = async () => ({
devices: [
bobWeb,
bobDesktop,
bobDesktopOffline,
bobDesktopNeverOnline,
],
});
});

afterEach(() => groupCall.leave());

it("doesn't clean up valid devices", async () => {
await groupCall.enter();
await mockClient.sendStateEvent(
room.roomId,
EventType.GroupCallMemberPrefix,
mkContent([bobWeb, bobDesktop]),
FAKE_USER_ID_2,
);

await groupCall.cleanMemberState();
expectDevices([bobWeb, bobDesktop]);
});

it("cleans up our own device if we're disconnected", async () => {
await mockClient.sendStateEvent(
room.roomId,
EventType.GroupCallMemberPrefix,
mkContent([bobWeb, bobDesktop]),
FAKE_USER_ID_2,
);

await groupCall.cleanMemberState();
expectDevices([bobDesktop]);
});

it("doesn't clean up the local device if entered via another session", async () => {
groupCall.enteredViaAnotherSession = true;
await mockClient.sendStateEvent(
room.roomId,
EventType.GroupCallMemberPrefix,
mkContent([bobWeb]),
FAKE_USER_ID_2,
);

await groupCall.cleanMemberState();
expectDevices([bobWeb]);
});

it("cleans up devices that have never been online", async () => {
await mockClient.sendStateEvent(
room.roomId,
EventType.GroupCallMemberPrefix,
mkContent([bobDesktop, bobDesktopNeverOnline]),
FAKE_USER_ID_2,
);

await groupCall.cleanMemberState();
expectDevices([bobDesktop]);
});

it("no-ops if there are no state events", async () => {
await groupCall.cleanMemberState();
expect(room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)).toBe(null);
});
});
});
24 changes: 21 additions & 3 deletions src/webrtc/groupCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,21 @@ export class GroupCall extends TypedEventEmitter<
this._creationTs = value;
}

private _enteredViaAnotherSession = false;

/**
* Whether the local device has entered this call via another session, such
* as a widget.
*/
public get enteredViaAnotherSession(): boolean {
return this._enteredViaAnotherSession;
}

public set enteredViaAnotherSession(value: boolean) {
this._enteredViaAnotherSession = value;
this.updateParticipants();
}

/**
* Executes the given callback on all calls in this group call.
* @param f The callback.
Expand Down Expand Up @@ -1170,7 +1185,7 @@ export class GroupCall extends TypedEventEmitter<

const participants = new Map<RoomMember, Map<string, ParticipantState>>();
const now = Date.now();
const entered = this.state === GroupCallState.Entered;
const entered = this.state === GroupCallState.Entered || this.enteredViaAnotherSession;
let nextExpiration = Infinity;

for (const e of this.getMemberStateEvents()) {
Expand Down Expand Up @@ -1344,8 +1359,11 @@ export class GroupCall extends TypedEventEmitter<
await this.updateDevices(devices => {
const newDevices = devices.filter(d => {
const device = deviceMap.get(d.device_id);
return device?.last_seen_ts !== undefined
&& !(d.device_id === this.client.getDeviceId()! && this.state !== GroupCallState.Entered);
return device?.last_seen_ts !== undefined && !(
d.device_id === this.client.getDeviceId()!
&& this.state !== GroupCallState.Entered
&& !this.enteredViaAnotherSession
);
});

// Skip the update if the devices are unchanged
Expand Down