diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index d71ae223b12..706f14f7b43 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -114,8 +114,11 @@ import { RoomSearchView } from "./RoomSearchView"; import eventSearch from "../../Searching"; import VoipUserMapper from "../../VoipUserMapper"; import { isCallEvent } from "./LegacyCallEventGrouper"; +import { WidgetType } from "../../widgets/WidgetType"; +import WidgetUtils from "../../utils/WidgetUtils"; const DEBUG = false; +const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; let debuglog = function (msg: string): void {}; const BROWSER_SUPPORTS_SANDBOX = "sandbox" in document.createElement("iframe"); @@ -483,6 +486,7 @@ export class RoomView extends React.Component { private onWidgetStoreUpdate = (): void => { if (!this.state.room) return; this.checkWidgets(this.state.room); + this.doMaybeRemoveOwnJitsiWidget(); }; private onWidgetEchoStoreUpdate = (): void => { @@ -503,6 +507,56 @@ export class RoomView extends React.Component { this.checkWidgets(this.state.room); }; + /** + * Removes the Jitsi widget from the current user if + * - Multiple Jitsi widgets have been added within {@link PREVENT_MULTIPLE_JITSI_WITHIN} + * - The last (server timestamp) of these widgets is from the currrent user + * This solves the issue if some people decide to start a conference and click the call button at the same time. + */ + private doMaybeRemoveOwnJitsiWidget(): void { + if (!this.state.roomId || !this.state.room || !this.context.client) return; + + const apps = this.context.widgetStore.getApps(this.state.roomId); + const jitsiApps = apps.filter((app) => app.eventId && WidgetType.JITSI.matches(app.type)); + + // less than two Jitsi widgets → nothing to do + if (jitsiApps.length < 2) return; + + const currentUserId = this.context.client.getSafeUserId(); + const createdByCurrentUser = jitsiApps.find((apps) => apps.creatorUserId === currentUserId); + + // no Jitsi widget from current user → nothing to do + if (!createdByCurrentUser) return; + + const createdByCurrentUserEvent = this.state.room.findEventById(createdByCurrentUser.eventId!); + + // widget event not found → nothing can be done + if (!createdByCurrentUserEvent) return; + + const createdByCurrentUserTs = createdByCurrentUserEvent.getTs(); + + // widget timestamp is empty → nothing can be done + if (!createdByCurrentUserTs) return; + + const lastCreatedByOtherTs = jitsiApps.reduce((maxByNow: number, app) => { + if (app.eventId === createdByCurrentUser.eventId) return maxByNow; + + const appCreateTs = this.state.room!.findEventById(app.eventId!)?.getTs() || 0; + return Math.max(maxByNow, appCreateTs); + }, 0); + + // last widget timestamp from other is empty → nothing can be done + if (!lastCreatedByOtherTs) return; + + if ( + createdByCurrentUserTs > lastCreatedByOtherTs && + createdByCurrentUserTs - lastCreatedByOtherTs < PREVENT_MULTIPLE_JITSI_WITHIN + ) { + // more than one Jitsi widget with the last one from the current user → remove it + WidgetUtils.setRoomWidget(this.state.roomId, createdByCurrentUser.id); + } + } + private checkWidgets = (room: Room): void => { this.setState({ hasPinnedWidgets: this.context.widgetLayoutStore.hasPinnedWidgets(room), diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index 704dc5f66bb..28b82b02cd9 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -21,7 +21,7 @@ import { mocked, MockedObject } from "jest-mock"; import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { EventType } from "matrix-js-sdk/src/matrix"; +import { EventType, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; import { fireEvent, render } from "@testing-library/react"; @@ -31,6 +31,9 @@ import { unmockPlatformPeg, wrapInMatrixClientContext, flushPromises, + mkEvent, + setupAsyncStoreWithClient, + filterConsole, } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { Action } from "../../../src/dispatcher/actions"; @@ -49,6 +52,9 @@ import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom"; import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; import { SdkContextClass, SDKContext } from "../../../src/contexts/SDKContext"; import VoipUserMapper from "../../../src/VoipUserMapper"; +import WidgetUtils from "../../../src/utils/WidgetUtils"; +import { WidgetType } from "../../../src/widgets/WidgetType"; +import WidgetStore from "../../../src/stores/WidgetStore"; const RoomView = wrapInMatrixClientContext(_RoomView); @@ -59,6 +65,9 @@ describe("RoomView", () => { let roomCount = 0; let stores: SdkContextClass; + // mute some noise + filterConsole("RVS update", "does not have an m.room.create event", "Current version: 1", "Version capability"); + beforeEach(async () => { mockPlatformPeg({ reload: () => {} }); stubClient(); @@ -359,4 +368,90 @@ describe("RoomView", () => { }); }); }); + + describe("when there is a RoomView", () => { + const widget1Id = "widget1"; + const widget2Id = "widget2"; + const otherUserId = "@other:example.com"; + + const addJitsiWidget = async (id: string, user: string, ts?: number): Promise => { + const widgetEvent = mkEvent({ + event: true, + room: room.roomId, + user, + type: "im.vector.modular.widgets", + content: { + id, + name: "Jitsi", + type: WidgetType.JITSI.preferred, + url: "https://example.com", + }, + skey: id, + ts, + }); + room.addLiveEvents([widgetEvent]); + room.currentState.setStateEvents([widgetEvent]); + cli.emit(RoomStateEvent.Events, widgetEvent, room.currentState, null); + await flushPromises(); + }; + + beforeEach(async () => { + jest.spyOn(WidgetUtils, "setRoomWidget"); + const widgetStore = WidgetStore.instance; + await setupAsyncStoreWithClient(widgetStore, cli); + getRoomViewInstance(); + }); + + const itShouldNotRemoveTheLastWidget = (): void => { + it("should not remove the last widget", (): void => { + expect(WidgetUtils.setRoomWidget).not.toHaveBeenCalledWith(room.roomId, widget2Id); + }); + }; + + describe("and there is a Jitsi widget from another user", () => { + beforeEach(async () => { + await addJitsiWidget(widget1Id, otherUserId, 10_000); + }); + + describe("and the current user adds a Jitsi widget after 10s", () => { + beforeEach(async () => { + await addJitsiWidget(widget2Id, cli.getSafeUserId(), 20_000); + }); + + it("the last Jitsi widget should be removed", () => { + expect(WidgetUtils.setRoomWidget).toHaveBeenCalledWith(room.roomId, widget2Id); + }); + }); + + describe("and the current user adds a Jitsi widget after two minutes", () => { + beforeEach(async () => { + await addJitsiWidget(widget2Id, cli.getSafeUserId(), 130_000); + }); + + itShouldNotRemoveTheLastWidget(); + }); + + describe("and the current user adds a Jitsi widget without timestamp", () => { + beforeEach(async () => { + await addJitsiWidget(widget2Id, cli.getSafeUserId()); + }); + + itShouldNotRemoveTheLastWidget(); + }); + }); + + describe("and there is a Jitsi widget from another user without timestamp", () => { + beforeEach(async () => { + await addJitsiWidget(widget1Id, otherUserId); + }); + + describe("and the current user adds a Jitsi widget", () => { + beforeEach(async () => { + await addJitsiWidget(widget2Id, cli.getSafeUserId(), 10_000); + }); + + itShouldNotRemoveTheLastWidget(); + }); + }); + }); });