Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit ceda77d

Browse files
authored
Proactively fix stuck devices in video rooms (#8587)
* Proactively fix stuck devices in video rooms * Fix tests * Explain why we're disabling the lint rule * Apply code review suggestions * Back VideoChannelStore's flags by SettingsStore instead of localStorage
1 parent 6f85110 commit ceda77d

File tree

9 files changed

+149
-49
lines changed

9 files changed

+149
-49
lines changed

src/Lifecycle.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialo
6060
import { setSentryUser } from "./sentry";
6161
import SdkConfig from "./SdkConfig";
6262
import { DialogOpener } from "./utils/DialogOpener";
63+
import VideoChannelStore from "./stores/VideoChannelStore";
64+
import { fixStuckDevices } from "./utils/VideoChannelUtils";
6365
import { Action } from "./dispatcher/actions";
6466
import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler";
6567

@@ -835,6 +837,11 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
835837
// Now that we have a MatrixClientPeg, update the Jitsi info
836838
Jitsi.getInstance().start();
837839

840+
// In case we disconnected uncleanly from a video room, clean up the stuck device
841+
if (VideoChannelStore.instance.roomId) {
842+
fixStuckDevices(MatrixClientPeg.get().getRoom(VideoChannelStore.instance.roomId), false);
843+
}
844+
838845
// dispatch that we finished starting up to wire up any other bits
839846
// of the matrix client that cannot be set prior to starting up.
840847
dis.dispatch({ action: 'client_started' });

src/components/structures/VideoRoomView.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import React, { FC, useContext, useState, useMemo } from "react";
17+
import React, { FC, useContext, useState, useMemo, useEffect } from "react";
1818
import { logger } from "matrix-js-sdk/src/logger";
1919
import { Room } from "matrix-js-sdk/src/models/room";
2020

2121
import MatrixClientContext from "../../contexts/MatrixClientContext";
2222
import { useEventEmitter } from "../../hooks/useEventEmitter";
2323
import WidgetUtils from "../../utils/WidgetUtils";
24-
import { addVideoChannel, getVideoChannel } from "../../utils/VideoChannelUtils";
24+
import { addVideoChannel, getVideoChannel, fixStuckDevices } from "../../utils/VideoChannelUtils";
2525
import WidgetStore, { IApp } from "../../stores/WidgetStore";
2626
import { UPDATE_EVENT } from "../../stores/AsyncStore";
2727
import VideoChannelStore, { VideoChannelEvent } from "../../stores/VideoChannelStore";
@@ -62,6 +62,12 @@ const VideoRoomView: FC<IProps> = ({ room, resizing }) => {
6262
}
6363
}, [room, widgetStoreReady, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
6464

65+
// We'll also take this opportunity to fix any stuck devices.
66+
// The linter thinks that store.connected should be a dependency, but we explicitly
67+
// *only* want this to happen at mount to avoid racing with normal device updates.
68+
// eslint-disable-next-line react-hooks/exhaustive-deps
69+
useEffect(() => { fixStuckDevices(room, store.connected); }, [room]);
70+
6571
const [connected, setConnected] = useState(store.connected && store.roomId === room.roomId);
6672
useEventEmitter(store, VideoChannelEvent.Connect, () => setConnected(store.roomId === room.roomId));
6773
useEventEmitter(store, VideoChannelEvent.Disconnect, () => setConnected(false));

src/settings/Settings.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,18 @@ export const SETTINGS: {[setting: string]: ISetting} = {
929929
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
930930
default: false,
931931
},
932+
"audioInputMuted": {
933+
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
934+
default: false,
935+
},
936+
"videoInputMuted": {
937+
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
938+
default: false,
939+
},
940+
"videoChannelRoomId": {
941+
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
942+
default: null,
943+
},
932944
[UIFeature.RoomHistorySettings]: {
933945
supportedLevels: LEVELS_UI_FEATURE,
934946
default: true,

src/stores/VideoChannelStore.ts

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,13 @@ import EventEmitter from "events";
1818
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
1919
import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api";
2020

21+
import SettingsStore from "../settings/SettingsStore";
22+
import { SettingLevel } from "../settings/SettingLevel";
2123
import defaultDispatcher from "../dispatcher/dispatcher";
2224
import { ActionPayload } from "../dispatcher/payloads";
2325
import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
2426
import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore";
25-
import {
26-
VIDEO_CHANNEL_MEMBER,
27-
IVideoChannelMemberContent,
28-
getVideoChannel,
29-
} from "../utils/VideoChannelUtils";
27+
import { getVideoChannel, addOurDevice, removeOurDevice } from "../utils/VideoChannelUtils";
3028
import { timeout } from "../utils/promise";
3129
import WidgetUtils from "../utils/WidgetUtils";
3230
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
@@ -83,9 +81,13 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
8381

8482
private activeChannel: ClientWidgetApi;
8583

86-
private _roomId: string;
87-
public get roomId(): string { return this._roomId; }
88-
private set roomId(value: string) { this._roomId = value; }
84+
// This is persisted to settings so we can detect unclean disconnects
85+
public get roomId(): string | null { return SettingsStore.getValue("videoChannelRoomId"); }
86+
private set roomId(value: string | null) {
87+
SettingsStore.setValue("videoChannelRoomId", null, SettingLevel.DEVICE, value);
88+
}
89+
90+
private get room(): Room { return this.matrixClient.getRoom(this.roomId); }
8991

9092
private _connected = false;
9193
public get connected(): boolean { return this._connected; }
@@ -95,18 +97,14 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
9597
public get participants(): IJitsiParticipant[] { return this._participants; }
9698
private set participants(value: IJitsiParticipant[]) { this._participants = value; }
9799

98-
private _audioMuted = localStorage.getItem("mx_audioMuted") === "true";
99-
public get audioMuted(): boolean { return this._audioMuted; }
100+
public get audioMuted(): boolean { return SettingsStore.getValue("audioInputMuted"); }
100101
public set audioMuted(value: boolean) {
101-
this._audioMuted = value;
102-
localStorage.setItem("mx_audioMuted", value.toString());
102+
SettingsStore.setValue("audioInputMuted", null, SettingLevel.DEVICE, value);
103103
}
104104

105-
private _videoMuted = localStorage.getItem("mx_videoMuted") === "true";
106-
public get videoMuted(): boolean { return this._videoMuted; }
105+
public get videoMuted(): boolean { return SettingsStore.getValue("videoInputMuted"); }
107106
public set videoMuted(value: boolean) {
108-
this._videoMuted = value;
109-
localStorage.setItem("mx_videoMuted", value.toString());
107+
SettingsStore.setValue("videoInputMuted", null, SettingLevel.DEVICE, value);
110108
}
111109

112110
public connect = async (roomId: string, audioDevice: MediaDeviceInfo, videoDevice: MediaDeviceInfo) => {
@@ -198,13 +196,13 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
198196
}
199197

200198
this.connected = true;
201-
this.matrixClient.getRoom(roomId).on(RoomEvent.MyMembership, this.onMyMembership);
199+
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
202200
window.addEventListener("beforeunload", this.setDisconnected);
203201

204202
this.emit(VideoChannelEvent.Connect, roomId);
205203

206204
// Tell others that we're connected, by adding our device to room state
207-
this.updateDevices(roomId, devices => Array.from(new Set(devices).add(this.matrixClient.getDeviceId())));
205+
await addOurDevice(this.room);
208206
};
209207

210208
public disconnect = async () => {
@@ -221,10 +219,11 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
221219

222220
public setDisconnected = async () => {
223221
const roomId = this.roomId;
222+
const room = this.room;
224223

225224
this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
226225
this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
227-
this.matrixClient.getRoom(roomId).off(RoomEvent.MyMembership, this.onMyMembership);
226+
room.off(RoomEvent.MyMembership, this.onMyMembership);
228227
window.removeEventListener("beforeunload", this.setDisconnected);
229228

230229
this.activeChannel = null;
@@ -235,11 +234,7 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
235234
this.emit(VideoChannelEvent.Disconnect, roomId);
236235

237236
// Tell others that we're disconnected, by removing our device from room state
238-
await this.updateDevices(roomId, devices => {
239-
const devicesSet = new Set(devices);
240-
devicesSet.delete(this.matrixClient.getDeviceId());
241-
return Array.from(devicesSet);
242-
});
237+
await removeOurDevice(room);
243238
};
244239

245240
private ack = (ev: CustomEvent<IWidgetApiRequest>) => {
@@ -248,18 +243,6 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
248243
this.activeChannel.transport.reply(ev.detail, {});
249244
};
250245

251-
private updateDevices = async (roomId: string, fn: (devices: string[]) => string[]) => {
252-
const room = this.matrixClient.getRoom(roomId);
253-
if (room.getMyMembership() !== "join") return;
254-
255-
const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, this.matrixClient.getUserId());
256-
const devices = devicesState?.getContent<IVideoChannelMemberContent>()?.devices ?? [];
257-
258-
await this.matrixClient.sendStateEvent(
259-
roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.matrixClient.getUserId(),
260-
);
261-
};
262-
263246
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
264247
this.ack(ev);
265248
// In case this hangup is caused by Jitsi Meet crashing at startup,

src/utils/VideoChannelUtils.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ limitations under the License.
1616

1717
import { useState } from "react";
1818
import { throttle } from "lodash";
19+
import { Optional } from "matrix-events-sdk";
20+
import { IMyDevice } from "matrix-js-sdk/src/client";
1921
import { CallType } from "matrix-js-sdk/src/webrtc/call";
2022
import { Room } from "matrix-js-sdk/src/models/room";
2123
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
@@ -26,14 +28,16 @@ import WidgetStore, { IApp } from "../stores/WidgetStore";
2628
import { WidgetType } from "../widgets/WidgetType";
2729
import WidgetUtils from "./WidgetUtils";
2830

29-
export const VIDEO_CHANNEL = "io.element.video";
30-
export const VIDEO_CHANNEL_MEMBER = "io.element.video.member";
31+
const STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60;
3132

32-
export interface IVideoChannelMemberContent {
33+
interface IVideoChannelMemberContent {
3334
// Connected device IDs
3435
devices: string[];
3536
}
3637

38+
export const VIDEO_CHANNEL = "io.element.video";
39+
export const VIDEO_CHANNEL_MEMBER = "io.element.video.member";
40+
3741
export const getVideoChannel = (roomId: string): IApp => {
3842
const apps = WidgetStore.instance.getApps(roomId);
3943
return apps.find(app => WidgetType.JITSI.matches(app.type) && app.id === VIDEO_CHANNEL);
@@ -72,3 +76,54 @@ export const useConnectedMembers = (
7276
}, throttleMs, { leading: true, trailing: true }));
7377
return members;
7478
};
79+
80+
const updateDevices = async (room: Optional<Room>, fn: (devices: string[] | null) => string[]) => {
81+
if (room?.getMyMembership() !== "join") return;
82+
83+
const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, room.client.getUserId());
84+
const devices = devicesState?.getContent<IVideoChannelMemberContent>()?.devices ?? [];
85+
const newDevices = fn(devices);
86+
87+
if (newDevices) {
88+
await room.client.sendStateEvent(
89+
room.roomId, VIDEO_CHANNEL_MEMBER, { devices: newDevices }, room.client.getUserId(),
90+
);
91+
}
92+
};
93+
94+
export const addOurDevice = async (room: Room) => {
95+
await updateDevices(room, devices => Array.from(new Set(devices).add(room.client.getDeviceId())));
96+
};
97+
98+
export const removeOurDevice = async (room: Room) => {
99+
await updateDevices(room, devices => {
100+
const devicesSet = new Set(devices);
101+
devicesSet.delete(room.client.getDeviceId());
102+
return Array.from(devicesSet);
103+
});
104+
};
105+
106+
/**
107+
* Fixes devices that may have gotten stuck in video channel member state after
108+
* an unclean disconnection, by filtering out logged out devices, inactive
109+
* devices, and our own device (if we're disconnected).
110+
* @param {Room} room The room to fix
111+
* @param {boolean} connectedLocalEcho Local echo of whether this device is connected
112+
*/
113+
export const fixStuckDevices = async (room: Room, connectedLocalEcho: boolean) => {
114+
const now = new Date().valueOf();
115+
const { devices: myDevices } = await room.client.getDevices();
116+
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
117+
118+
await updateDevices(room, devices => {
119+
const newDevices = devices.filter(d => {
120+
const device = deviceMap.get(d);
121+
return device?.last_seen_ts
122+
&& !(d === room.client.getDeviceId() && !connectedLocalEcho)
123+
&& (now - device.last_seen_ts) < STUCK_DEVICE_TIMEOUT_MS;
124+
});
125+
126+
// Skip the update if the devices are unchanged
127+
return newDevices.length === devices.length ? null : newDevices;
128+
});
129+
};

test/components/structures/VideoRoomView-test.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ limitations under the License.
1717
import React from "react";
1818
import { mount } from "enzyme";
1919
import { act } from "react-dom/test-utils";
20-
import { MatrixClient } from "matrix-js-sdk/src/client";
20+
import { mocked } from "jest-mock";
21+
import { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client";
2122
import { Room } from "matrix-js-sdk/src/models/room";
2223
import { MatrixWidgetType } from "matrix-widget-api";
2324

@@ -27,9 +28,11 @@ import {
2728
StubVideoChannelStore,
2829
mkRoom,
2930
wrapInMatrixClientContext,
31+
mockStateEventImplementation,
32+
mkVideoChannelMember,
3033
} from "../../test-utils";
3134
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
32-
import { VIDEO_CHANNEL } from "../../../src/utils/VideoChannelUtils";
35+
import { VIDEO_CHANNEL, VIDEO_CHANNEL_MEMBER } from "../../../src/utils/VideoChannelUtils";
3336
import WidgetStore from "../../../src/stores/WidgetStore";
3437
import _VideoRoomView from "../../../src/components/structures/VideoRoomView";
3538
import VideoLobby from "../../../src/components/views/voip/VideoLobby";
@@ -64,6 +67,37 @@ describe("VideoRoomView", () => {
6467
room = mkRoom(cli, "!1:example.org");
6568
});
6669

70+
it("removes stuck devices on mount", async () => {
71+
// Simulate an unclean disconnect
72+
store.roomId = "!1:example.org";
73+
74+
const devices: IMyDevice[] = [
75+
{
76+
device_id: cli.getDeviceId(),
77+
last_seen_ts: new Date().valueOf(),
78+
},
79+
{
80+
device_id: "went offline 2 hours ago",
81+
last_seen_ts: new Date().valueOf() - 1000 * 60 * 60 * 2,
82+
},
83+
];
84+
mocked(cli).getDevices.mockResolvedValue({ devices });
85+
86+
// Make both devices be stuck
87+
mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([
88+
mkVideoChannelMember(cli.getUserId(), devices.map(d => d.device_id)),
89+
]));
90+
91+
mount(<VideoRoomView room={room} resizing={false} />);
92+
// Wait for state to settle
93+
await act(() => Promise.resolve());
94+
95+
// All devices should have been removed
96+
expect(cli.sendStateEvent).toHaveBeenLastCalledWith(
97+
"!1:example.org", VIDEO_CHANNEL_MEMBER, { devices: [] }, cli.getUserId(),
98+
);
99+
});
100+
67101
it("shows lobby and keeps widget loaded when disconnected", async () => {
68102
const view = mount(<VideoRoomView room={room} resizing={false} />);
69103
// Wait for state to settle

test/stores/VideoChannelStore-test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ limitations under the License.
1717
import { mocked } from "jest-mock";
1818
import { Widget, ClientWidgetApi, MatrixWidgetType, IWidgetApiRequest } from "matrix-widget-api";
1919

20-
import { stubClient, setupAsyncStoreWithClient } from "../test-utils";
20+
import { stubClient, setupAsyncStoreWithClient, mkRoom } from "../test-utils";
2121
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
2222
import WidgetStore, { IApp } from "../../src/stores/WidgetStore";
2323
import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore";
@@ -51,6 +51,7 @@ describe("VideoChannelStore", () => {
5151
const cli = MatrixClientPeg.get();
5252
setupAsyncStoreWithClient(WidgetMessagingStore.instance, cli);
5353
setupAsyncStoreWithClient(store, cli);
54+
mocked(cli).getRoom.mockReturnValue(mkRoom(cli, "!1:example.org"));
5455

5556
let resolveMessageSent: () => void;
5657
messageSent = new Promise(resolve => resolveMessageSent = resolve);

test/test-utils/test-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export function createTestClient(): MatrixClient {
7979
getUserId: jest.fn().mockReturnValue("@userId:matrix.rog"),
8080
getUser: jest.fn().mockReturnValue({ on: jest.fn() }),
8181
getDeviceId: jest.fn().mockReturnValue("ABCDEFGHI"),
82+
getDevices: jest.fn().mockResolvedValue({ devices: [{ device_id: "ABCDEFGHI" }] }),
8283
credentials: { userId: "@userId:matrix.rog" },
8384

8485
getPushActionsForEvent: jest.fn(),

test/test-utils/video.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,25 @@ import { VIDEO_CHANNEL_MEMBER } from "../../src/utils/VideoChannelUtils";
2222
import VideoChannelStore, { VideoChannelEvent, IJitsiParticipant } from "../../src/stores/VideoChannelStore";
2323

2424
export class StubVideoChannelStore extends EventEmitter {
25-
private _roomId: string;
26-
public get roomId(): string { return this._roomId; }
25+
private _roomId: string | null;
26+
public get roomId(): string | null { return this._roomId; }
27+
public set roomId(value: string | null) { this._roomId = value; }
2728
private _connected: boolean;
2829
public get connected(): boolean { return this._connected; }
2930
public get participants(): IJitsiParticipant[] { return []; }
3031

3132
public startConnect = (roomId: string) => {
32-
this._roomId = roomId;
33+
this.roomId = roomId;
3334
this.emit(VideoChannelEvent.StartConnect, roomId);
3435
};
3536
public connect = jest.fn((roomId: string) => {
36-
this._roomId = roomId;
37+
this.roomId = roomId;
3738
this._connected = true;
3839
this.emit(VideoChannelEvent.Connect, roomId);
3940
});
4041
public disconnect = jest.fn(() => {
4142
const roomId = this._roomId;
42-
this._roomId = null;
43+
this.roomId = null;
4344
this._connected = false;
4445
this.emit(VideoChannelEvent.Disconnect, roomId);
4546
});

0 commit comments

Comments
 (0)