diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 6680345bbcf..f3b79884697 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -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"; @@ -106,7 +112,8 @@ interface ActiveLoadedCallEventProps { const ActiveLoadedCallEvent = forwardRef(({ 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(); @@ -138,8 +145,8 @@ const ActiveLoadedCallEvent = forwardRef(({ mxE participants={participants} buttonText={buttonText} buttonKind={buttonKind} - buttonDisabled={Boolean(joinCallButtonDisabledTooltip)} - buttonTooltip={joinCallButtonDisabledTooltip} + buttonDisabled={joinCallButtonDisabled} + buttonTooltip={joinCallButtonTooltip} onButtonClick={onButtonClick} />; }); diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 65439e56c97..2a795e4bf1b 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -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"; @@ -110,11 +116,12 @@ const MAX_FACES = 8; interface LobbyProps { room: Room; connect: () => Promise; - joinCallButtonDisabledTooltip?: string; + joinCallButtonTooltip?: string; + joinCallButtonDisabled?: boolean; children?: ReactNode; } -export const Lobby: FC = ({ room, joinCallButtonDisabledTooltip, connect, children }) => { +export const Lobby: FC = ({ room, joinCallButtonDisabled, joinCallButtonTooltip, connect, children }) => { const [connecting, setConnecting] = useState(false); const me = useMemo(() => room.getMember(room.myUserId)!, [room]); const videoRef = useRef(null); @@ -237,11 +244,11 @@ export const Lobby: FC = ({ room, joinCallButtonDisabledTooltip, con ; }; @@ -323,7 +330,8 @@ const JoinCallView: FC = ({ 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 @@ -350,7 +358,8 @@ const JoinCallView: FC = ({ room, resizing, call }) => { lobby = { facePile } ; diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index 178514f2629..cf9bbee0d0d 100644 --- a/src/hooks/useCall.ts +++ b/src/hooks/useCall.ts @@ -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"; @@ -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)); @@ -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, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 48d7e9b8a35..eb710a4d53d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/models/Call.ts b/src/models/Call.ts index 455b08e6a57..6a50fe90d8d 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -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"; @@ -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; @@ -785,6 +788,16 @@ export class ElementCall extends Call { audioInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null, ): Promise { + 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 }, + }, + }); + try { await this.messaging!.transport.send(ElementWidgetActions.JoinCall, { audioInput: audioInput?.label ?? null, @@ -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); @@ -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 { @@ -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)) diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index c8eff9dc828..c5e363089b7 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -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"; @@ -45,12 +45,13 @@ interface JoinCallButtonWithCallProps { } function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) { - const tooltip = useJoinCallButtonDisabledTooltip(call); + const tooltip = useJoinCallButtonTooltip(call); + const disabled = useJoinCallButtonDisabled(call); return diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index d8a455d0f32..df57472638b 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -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"; @@ -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"); @@ -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(