diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 7000792..8ba26b4 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -31,10 +31,10 @@ import { logger } from "matrix-js-sdk/src/logger"; import { ErrorView } from "./FullScreenView"; import { initClient, - initMatroskaClient, defaultHomeserver, CryptoStoreIntegrityError, } from "./matrix-utils"; +import { widget } from "./widget"; declare global { interface Window { @@ -100,16 +100,12 @@ export const ClientProvider: FC = ({ children }) => { const init = async (): Promise< Pick > => { - const query = new URLSearchParams(window.location.search); - const widgetId = query.get("widgetId"); - const parentUrl = query.get("parentUrl"); - - if (widgetId && parentUrl) { - // We're inside a widget, so let's engage *Matroska mode* - logger.log("Using a Matroska client"); + if (widget) { + // We're inside a widget, so let's engage *matryoshka mode* + logger.log("Using a matryoshka client"); return { - client: await initMatroskaClient(widgetId, parentUrl), + client: await widget.client, isPasswordlessUser: false, }; } else { diff --git a/src/LazyEventEmitter.ts b/src/LazyEventEmitter.ts new file mode 100644 index 0000000..bbe68c7 --- /dev/null +++ b/src/LazyEventEmitter.ts @@ -0,0 +1,90 @@ +/* +Copyright 2022 Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import EventEmitter from "events"; + +type NonEmptyArray = [T, ...T[]]; + +/** + * An event emitter that lets events pile up in a backlog until a listener is + * present, at which point any events that were missed are re-emitted. + */ +export class LazyEventEmitter extends EventEmitter { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private eventBacklogs = new Map>(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public emit(type: string | symbol, ...args: any[]): boolean { + const hasListeners = super.emit(type, ...args); + + if (!hasListeners) { + // The event was missed, so add it to the backlog + const backlog = this.eventBacklogs.get(type); + if (backlog) { + backlog.push(args); + } else { + // Start a new backlog + this.eventBacklogs.set(type, [args]); + } + } + + return hasListeners; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public on(type: string | symbol, listener: (...args: any[]) => void): this { + super.on(type, listener); + + const backlog = this.eventBacklogs.get(type); + if (backlog) { + // That was the first listener for this type, so let's send it all the + // events that have piled up + for (const args of backlog) super.emit(type, ...args); + // Backlog is now clear + this.eventBacklogs.delete(type); + } + + return this; + } + + public addListener( + type: string | symbol, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: (...args: any[]) => void + ): this { + return this.on(type, listener); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public once(type: string | symbol, listener: (...args: any[]) => void): this { + super.once(type, listener); + + const backlog = this.eventBacklogs.get(type); + if (backlog) { + // That was the first listener for this type, so let's send it the first + // of the events that have piled up + super.emit(type, ...backlog[0]); + // Clear the event from the backlog + if (backlog.length === 1) { + this.eventBacklogs.delete(type); + } else { + backlog.shift(); + } + } + + return this; + } +} diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts index 4c25d17..3d93a33 100644 --- a/src/matrix-utils.ts +++ b/src/matrix-utils.ts @@ -5,23 +5,18 @@ import { MemoryStore } from "matrix-js-sdk/src/store/memory"; import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store"; import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store"; -import { - createClient, - createRoomWidgetClient, - MatrixClient, -} from "matrix-js-sdk/src/matrix"; +import { createClient } from "matrix-js-sdk/src/matrix"; import { ICreateClientOpts } from "matrix-js-sdk/src/matrix"; import { ClientEvent } from "matrix-js-sdk/src/client"; -import { EventType } from "matrix-js-sdk/src/@types/event"; import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; -import { WidgetApi } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; import { GroupCallIntent, GroupCallType, } from "matrix-js-sdk/src/webrtc/groupCall"; +import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { Room } from "matrix-js-sdk/src/models/room"; import IndexedDBWorker from "./IndexedDBWorker?worker"; import { getRoomParams } from "./room/useRoomParams"; @@ -64,73 +59,6 @@ function waitForSync(client: MatrixClient) { }); } -/** - * Initialises and returns a new widget-API-based Matrix Client. - * @param widgetId The ID of the widget that the app is running inside. - * @param parentUrl The URL of the parent client. - * @returns The MatrixClient instance - */ -export async function initMatroskaClient( - widgetId: string, - parentUrl: string -): Promise { - // In this mode, we use a special client which routes all requests through - // the host application via the widget API - - const { roomId, userId, deviceId } = getRoomParams(); - if (!roomId) throw new Error("Room ID must be supplied"); - if (!userId) throw new Error("User ID must be supplied"); - if (!deviceId) throw new Error("Device ID must be supplied"); - - // These are all the event types the app uses - const sendState = [ - { eventType: EventType.GroupCallPrefix }, - { eventType: EventType.GroupCallMemberPrefix, stateKey: userId }, - ]; - const receiveState = [ - { eventType: EventType.RoomMember }, - { eventType: EventType.GroupCallPrefix }, - { eventType: EventType.GroupCallMemberPrefix }, - ]; - const sendRecvToDevice = [ - EventType.CallInvite, - EventType.CallCandidates, - EventType.CallAnswer, - EventType.CallHangup, - EventType.CallReject, - EventType.CallSelectAnswer, - EventType.CallNegotiate, - EventType.CallSDPStreamMetadataChanged, - EventType.CallSDPStreamMetadataChangedPrefix, - EventType.CallReplaces, - "org.matrix.call_duplicate_session", - ]; - - // Since all data should be coming from the host application, there's no - // need to persist anything, and therefore we can use the default stores - // We don't even need to set up crypto - const client = createRoomWidgetClient( - new WidgetApi(widgetId, new URL(parentUrl).origin), - { - sendState, - receiveState, - sendToDevice: sendRecvToDevice, - receiveToDevice: sendRecvToDevice, - turnServers: true, - }, - roomId, - { - baseUrl: "", - userId, - deviceId, - timelineSupport: true, - } - ); - - await client.startClient(); - return client; -} - /** * Initialises and returns a new standalone Matrix Client. * If true is passed for the 'restore' parameter, a check will be made diff --git a/src/widget.ts b/src/widget.ts new file mode 100644 index 0000000..b6e7b98 --- /dev/null +++ b/src/widget.ts @@ -0,0 +1,140 @@ +/* +Copyright 2022 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { logger } from "matrix-js-sdk/src/logger"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { createRoomWidgetClient } from "matrix-js-sdk/src/matrix"; +import { WidgetApi, MatrixCapabilities } from "matrix-widget-api"; + +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { IWidgetApiRequest } from "matrix-widget-api"; +import { LazyEventEmitter } from "./LazyEventEmitter"; +import { getRoomParams } from "./room/useRoomParams"; + +// Subset of the actions in matrix-react-sdk +export enum ElementWidgetActions { + JoinCall = "io.element.join", + HangupCall = "im.vector.hangup", + TileLayout = "io.element.tile_layout", + SpotlightLayout = "io.element.spotlight_layout", +} + +export interface JoinCallData { + audioInput: string | null; + videoInput: string | null; +} + +interface WidgetHelpers { + api: WidgetApi; + lazyActions: LazyEventEmitter; + client: Promise; +} + +/** + * A point of access to the widget API, if the app is running as a widget. This + * is declared and initialized on the top level because the widget messaging + * needs to be set up ASAP on load to ensure it doesn't miss any requests. + */ +export const widget: WidgetHelpers | null = (() => { + try { + const query = new URLSearchParams(window.location.search); + const widgetId = query.get("widgetId"); + const parentUrl = query.get("parentUrl"); + + if (widgetId && parentUrl) { + const parentOrigin = new URL(parentUrl).origin; + logger.info("Widget API is available"); + const api = new WidgetApi(widgetId, parentOrigin); + api.requestCapability(MatrixCapabilities.AlwaysOnScreen); + + // Set up the lazy action emitter, but only for select actions that we + // intend for the app to handle + const lazyActions = new LazyEventEmitter(); + [ + ElementWidgetActions.JoinCall, + ElementWidgetActions.HangupCall, + ElementWidgetActions.TileLayout, + ElementWidgetActions.SpotlightLayout, + ].forEach((action) => { + api.on(`action:${action}`, (ev: CustomEvent) => { + ev.preventDefault(); + lazyActions.emit(action, ev); + }); + }); + + // Now, initialize the matryoshka MatrixClient (so named because it routes + // all requests through the host client via the widget API) + // We need to do this now rather than later because it has capabilities to + // request, and is responsible for starting the transport (should it be?) + + const { roomId, userId, deviceId } = getRoomParams(); + if (!roomId) throw new Error("Room ID must be supplied"); + if (!userId) throw new Error("User ID must be supplied"); + if (!deviceId) throw new Error("Device ID must be supplied"); + + // These are all the event types the app uses + const sendState = [ + { eventType: EventType.GroupCallPrefix }, + { eventType: EventType.GroupCallMemberPrefix, stateKey: userId }, + ]; + const receiveState = [ + { eventType: EventType.RoomMember }, + { eventType: EventType.GroupCallPrefix }, + { eventType: EventType.GroupCallMemberPrefix }, + ]; + const sendRecvToDevice = [ + EventType.CallInvite, + EventType.CallCandidates, + EventType.CallAnswer, + EventType.CallHangup, + EventType.CallReject, + EventType.CallSelectAnswer, + EventType.CallNegotiate, + EventType.CallSDPStreamMetadataChanged, + EventType.CallSDPStreamMetadataChangedPrefix, + EventType.CallReplaces, + "org.matrix.call_duplicate_session", + ]; + + const client = createRoomWidgetClient( + api, + { + sendState, + receiveState, + sendToDevice: sendRecvToDevice, + receiveToDevice: sendRecvToDevice, + turnServers: true, + }, + roomId, + { + baseUrl: "", + userId, + deviceId, + timelineSupport: true, + } + ); + const clientPromise = client.startClient().then(() => client); + + return { api, lazyActions, client: clientPromise }; + } else { + logger.info("No widget API available"); + return null; + } + } catch (e) { + logger.warn("Continuing without the widget API", e); + return null; + } +})();