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

Disconnect other connected devices (of the same user) when joining an Element call #9379

Merged
15 changes: 11 additions & 4 deletions src/components/views/messages/CallEvent.tsx
Expand Up @@ -20,7 +20,13 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Call, ConnectionState } from "../../../models/Call";
import { _t } from "../../../languageHandler";
import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall";
import {
useCall,
useConnectionState,
useJoinCallButtonDisabled,
useJoinCallButtonTooltip,
useParticipants,
} from "../../../hooks/useCall";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
Expand Down Expand Up @@ -106,7 +112,8 @@ interface ActiveLoadedCallEventProps {
const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => {
const connectionState = useConnectionState(call);
const participants = useParticipants(call);
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
const joinCallButtonDisabled = useJoinCallButtonDisabled(call);

const connect = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
Expand Down Expand Up @@ -138,8 +145,8 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
participants={participants}
buttonText={buttonText}
buttonKind={buttonKind}
buttonDisabled={Boolean(joinCallButtonDisabledTooltip)}
buttonTooltip={joinCallButtonDisabledTooltip}
buttonDisabled={joinCallButtonDisabled}
buttonTooltip={joinCallButtonTooltip}
onButtonClick={onButtonClick}
/>;
});
Expand Down
23 changes: 16 additions & 7 deletions src/components/views/voip/CallView.tsx
Expand Up @@ -22,7 +22,13 @@ import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { ConnectionState } from "../../../models/Call";
import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call";
import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall";
import {
useCall,
useConnectionState,
useJoinCallButtonDisabled,
useJoinCallButtonTooltip,
useParticipants,
} from "../../../hooks/useCall";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler";
Expand Down Expand Up @@ -110,11 +116,12 @@ const MAX_FACES = 8;
interface LobbyProps {
room: Room;
connect: () => Promise<void>;
joinCallButtonDisabledTooltip?: string;
joinCallButtonTooltip?: string;
joinCallButtonDisabled?: boolean;
children?: ReactNode;
}

export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, connect, children }) => {
export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabled, joinCallButtonTooltip, connect, children }) => {
const [connecting, setConnecting] = useState(false);
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
const videoRef = useRef<HTMLVideoElement>(null);
Expand Down Expand Up @@ -237,11 +244,11 @@ export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, con
<AccessibleTooltipButton
className="mx_CallView_connectButton"
kind="primary"
disabled={connecting || Boolean(joinCallButtonDisabledTooltip)}
disabled={connecting || joinCallButtonDisabled}
onClick={onConnectClick}
title={_t("Join")}
label={_t("Join")}
tooltip={connecting ? _t("Connecting") : joinCallButtonDisabledTooltip}
tooltip={connecting ? _t("Connecting") : joinCallButtonTooltip}
/>
</div>;
};
Expand Down Expand Up @@ -323,7 +330,8 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
const cli = useContext(MatrixClientContext);
const connected = isConnected(useConnectionState(call));
const participants = useParticipants(call);
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
const joinCallButtonDisabled = useJoinCallButtonDisabled(call);

const connect = useCallback(async () => {
// Disconnect from any other active calls first, since we don't yet support holding
Expand All @@ -350,7 +358,8 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
lobby = <Lobby
room={room}
connect={connect}
joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip}
joinCallButtonTooltip={joinCallButtonTooltip}
joinCallButtonDisabled={joinCallButtonDisabled}
>
{ facePile }
</Lobby>;
Expand Down
23 changes: 21 additions & 2 deletions src/hooks/useCall.ts
Expand Up @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";

import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Call, ConnectionState, ElementCall, Layout } from "../models/Call";
Expand All @@ -24,6 +24,7 @@ import { CallStore, CallStoreEvent } from "../stores/CallStore";
import { useEventEmitter } from "./useEventEmitter";
import SdkConfig, { DEFAULTS } from "../SdkConfig";
import { _t } from "../languageHandler";
import { MatrixClientPeg } from "../MatrixClientPeg";

export const useCall = (roomId: string): Call | null => {
const [call, setCall] = useState(() => CallStore.instance.getCall(roomId));
Expand Down Expand Up @@ -56,15 +57,33 @@ export const useFull = (call: Call): boolean => {
);
};

export const useJoinCallButtonDisabledTooltip = (call: Call): string | null => {
export const useIsAlreadyParticipant = (call: Call): boolean => {
const client = MatrixClientPeg.get();
const participants = useParticipants(call);

return useMemo(() => {
return participants.has(client.getRoom(call.roomId).getMember(client.getUserId()));
}, [participants, client, call]);
};

export const useJoinCallButtonTooltip = (call: Call): string | null => {
const isFull = useFull(call);
const state = useConnectionState(call);
const isAlreadyParticipant = useIsAlreadyParticipant(call);

if (state === ConnectionState.Connecting) return _t("Connecting");
if (isFull) return _t("Sorry — this call is currently full");
if (isAlreadyParticipant) return _t("You have already joined this call from another device");
return null;
};

export const useJoinCallButtonDisabled = (call: Call): boolean => {
const isFull = useFull(call);
const state = useConnectionState(call);

return isFull || state === ConnectionState.Connecting;
};

export const useLayout = (call: ElementCall): Layout =>
useTypedEventEmitterState(
call,
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Expand Up @@ -1021,6 +1021,7 @@
"This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
"Connecting": "Connecting",
"Sorry — this call is currently full": "Sorry — this call is currently full",
"You have already joined this call from another device": "You have already joined this call from another device",
"Create account": "Create account",
"You made it!": "You made it!",
"Find and invite your friends": "Find and invite your friends",
Expand Down
36 changes: 33 additions & 3 deletions src/models/Call.ts
Expand Up @@ -17,7 +17,7 @@ limitations under the License.
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
Expand Down Expand Up @@ -606,8 +606,11 @@ export interface ElementCallMemberContent {
export class ElementCall extends Call {
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call");
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call.member");
public static readonly DUPLICATE_CALL_DEVICE_EVENT_TYPE = "io.element.duplicate_call_device";
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour

private kickedOutByAnotherDevice = false;
private connectionTime: number | null = null;
private participantsExpirationTimer: number | null = null;
private terminationTimer: number | null = null;

Expand Down Expand Up @@ -785,6 +788,16 @@ export class ElementCall extends Call {
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
this.kickedOutByAnotherDevice = false;
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);

this.connectionTime = Date.now();
await this.client.sendToDevice(ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE, {
[this.client.getUserId()]: {
"*": { device_id: this.client.getDeviceId(), timestamp: this.connectionTime },
},
});
robintown marked this conversation as resolved.
Show resolved Hide resolved

try {
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
Expand All @@ -808,6 +821,7 @@ export class ElementCall extends Call {
}

public setDisconnected() {
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
Expand Down Expand Up @@ -845,8 +859,13 @@ export class ElementCall extends Call {
}

private get mayTerminate(): boolean {
return this.groupCall.getContent()["m.intent"] !== "m.room"
&& this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client);
if (this.kickedOutByAnotherDevice) return false;
if (this.groupCall.getContent()["m.intent"] === "m.room") return false;
if (
!this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client)
) return false;

return true;
}

private async terminate(): Promise<void> {
Expand All @@ -868,6 +887,17 @@ export class ElementCall extends Call {
if ("m.terminated" in newGroupCall.getContent()) this.destroy();
};

private onToDeviceEvent = (event: MatrixEvent): void => {
const content = event.getContent();
if (event.getType() !== ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE) return;
if (event.getSender() !== this.client.getUserId()) return;
if (content.device_id === this.client.getDeviceId()) return;
if (content.timestamp <= this.connectionTime) return;

this.kickedOutByAnotherDevice = true;
this.disconnect();
};

private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => {
if (
(state === ConnectionState.Connected && !isConnected(prevState))
Expand Down
7 changes: 4 additions & 3 deletions src/toasts/IncomingCallToast.tsx
Expand Up @@ -30,7 +30,7 @@ import {
LiveContentSummaryWithCall,
LiveContentType,
} from "../components/views/rooms/LiveContentSummary";
import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall";
import { useCall, useJoinCallButtonDisabled, useJoinCallButtonTooltip } from "../hooks/useCall";
import { useRoomState } from "../hooks/useRoomState";
import { ButtonEvent } from "../components/views/elements/AccessibleButton";
import { useDispatcher } from "../hooks/useDispatcher";
Expand All @@ -45,12 +45,13 @@ interface JoinCallButtonWithCallProps {
}

function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) {
const tooltip = useJoinCallButtonDisabledTooltip(call);
const tooltip = useJoinCallButtonTooltip(call);
const disabled = useJoinCallButtonDisabled(call);

return <AccessibleTooltipButton
className="mx_IncomingCallToast_joinButton"
onClick={onClick}
disabled={Boolean(tooltip)}
disabled={disabled}
tooltip={tooltip}
kind="primary"
>
Expand Down
75 changes: 74 additions & 1 deletion test/models/Call-test.ts
Expand Up @@ -18,10 +18,11 @@ import EventEmitter from "events";
import { mocked } from "jest-mock";
import { waitFor } from "@testing-library/react";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import { PendingEventOrdering } from "matrix-js-sdk/src/client";
import { ClientEvent, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";

import type { Mocked } from "jest-mock";
import type { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client";
Expand Down Expand Up @@ -85,6 +86,7 @@ const setUpClientRoomAndStores = (): {
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]);
client.getUserId.mockReturnValue(alice.userId);
client.getDeviceId.mockReturnValue("alices_device");
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => {
if (roomId !== room.roomId) throw new Error("Unknown room");
Expand Down Expand Up @@ -814,6 +816,77 @@ describe("ElementCall", () => {
call.off(CallEvent.Destroy, onDestroy);
});

describe("being kicked out by another device", () => {
const onDestroy = jest.fn();

beforeEach(async () => {
await call.connect();
call.on(CallEvent.Destroy, onDestroy);

jest.advanceTimersByTime(100);
jest.clearAllMocks();
});

afterEach(() => {
call.off(CallEvent.Destroy, onDestroy);
});

it("does not terminate the call if we are the last", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
getSender: () => (client.getUserId()),
} as MatrixEvent);

expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeTruthy();
});

it("ignores messages from our device", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getSender: () => (client.getUserId()),
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: client.getDeviceId(), timestamp: Date.now() }),
} as MatrixEvent);

expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeFalsy();
expect(onDestroy).not.toHaveBeenCalled();
});

it("ignores messages from other users", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getSender: () => (bob.userId),
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
} as MatrixEvent);

expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeFalsy();
expect(onDestroy).not.toHaveBeenCalled();
});

it("ignores messages from the past", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getSender: () => (client.getUserId()),
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: "random_device_id", timestamp: 0 }),
} as MatrixEvent);

expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeFalsy();
expect(onDestroy).not.toHaveBeenCalled();
});
});

it("ends the call after a random delay if the last participant leaves without ending it", async () => {
// Bob connects
await client.sendStateEvent(
Expand Down