Files
matrix-quotes-bot/node_modules/matrix-widget-api/src/ClientWidgetApi.ts

1434 lines
64 KiB
TypeScript
Raw Normal View History

2026-09-12 23:57:45 -04:00
/*
* Copyright 2020 - 2024 The 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";
import { ITransport } from "./transport/ITransport";
import { Widget } from "./models/Widget";
import { PostmessageTransport } from "./transport/PostmessageTransport";
import { WidgetApiDirection } from "./interfaces/WidgetApiDirection";
import { IWidgetApiRequest, IWidgetApiRequestEmptyData } from "./interfaces/IWidgetApiRequest";
import { IContentLoadedActionRequest } from "./interfaces/ContentLoadedAction";
import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction";
import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse";
import {
Capability,
MatrixCapabilities,
getTimelineRoomIDFromCapability,
isTimelineCapability,
} from "./interfaces/Capabilities";
import { IOpenIDUpdate, ISendEventDetails, ISendDelayedEventDetails, WidgetDriver } from "./driver/WidgetDriver";
import {
ICapabilitiesActionResponseData,
INotifyCapabilitiesActionRequestData,
IRenegotiateCapabilitiesActionRequest,
} from "./interfaces/CapabilitiesAction";
import {
ISupportedVersionsActionRequest,
ISupportedVersionsActionResponseData,
} from "./interfaces/SupportedVersionsAction";
import { ApiVersion, CurrentApiVersions, UnstableApiVersion } from "./interfaces/ApiVersion";
import { IScreenshotActionResponseData } from "./interfaces/ScreenshotAction";
import { IVisibilityActionRequestData } from "./interfaces/VisibilityAction";
import { IWidgetApiAcknowledgeResponseData, IWidgetApiResponseData } from "./interfaces/IWidgetApiResponse";
import {
IModalWidgetButtonClickedRequestData,
IModalWidgetOpenRequestData,
IModalWidgetOpenRequestDataButton,
IModalWidgetReturnData,
} from "./interfaces/ModalWidgetActions";
import {
ISendEventFromWidgetActionRequest,
ISendEventFromWidgetResponseData,
ISendEventToWidgetRequestData,
} from "./interfaces/SendEventAction";
import {
ISendToDeviceFromWidgetActionRequest,
ISendToDeviceFromWidgetResponseData,
ISendToDeviceToWidgetRequestData,
} from "./interfaces/SendToDeviceAction";
import { EventDirection, EventKind, WidgetEventCapability } from "./models/WidgetEventCapability";
import { IRoomEvent } from "./interfaces/IRoomEvent";
import {
IGetOpenIDActionRequest,
IGetOpenIDActionResponseData,
IOpenIDCredentials,
OpenIDRequestState,
} from "./interfaces/GetOpenIDAction";
import { SimpleObservable } from "./util/SimpleObservable";
import { IOpenIDCredentialsActionRequestData } from "./interfaces/OpenIDCredentialsAction";
import { INavigateActionRequest } from "./interfaces/NavigateAction";
import { IReadEventFromWidgetActionRequest, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction";
import {
ITurnServer,
IWatchTurnServersRequest,
IUnwatchTurnServersRequest,
IUpdateTurnServersRequestData,
} from "./interfaces/TurnServerActions";
import { Symbols } from "./Symbols";
import {
IReadRelationsFromWidgetActionRequest,
IReadRelationsFromWidgetResponseData,
} from "./interfaces/ReadRelationsAction";
import {
IUserDirectorySearchFromWidgetActionRequest,
IUserDirectorySearchFromWidgetResponseData,
} from "./interfaces/UserDirectorySearchAction";
import {
IReadRoomAccountDataFromWidgetActionRequest,
IReadRoomAccountDataFromWidgetResponseData,
} from "./interfaces/ReadRoomAccountDataAction";
import {
IGetMediaConfigActionFromWidgetActionRequest,
IGetMediaConfigActionFromWidgetResponseData,
} from "./interfaces/GetMediaConfigAction";
import {
IRtcTransportsFromWidgetActionRequest,
IRtcTransportsFromWidgetResponseData,
} from "./interfaces/RtcTransportsAction";
import {
IUpdateDelayedEventFromWidgetActionRequest,
UpdateDelayedEventAction,
} from "./interfaces/UpdateDelayedEventAction";
import {
IUploadFileActionFromWidgetActionRequest,
IUploadFileActionFromWidgetResponseData,
} from "./interfaces/UploadFileAction";
import {
IDownloadFileActionFromWidgetActionRequest,
IDownloadFileActionFromWidgetResponseData,
} from "./interfaces/DownloadFileAction";
import {
IRtcLivekitDelegateDelayedLeaveFromWidgetActionRequest,
IRtcLivekitDelegateDelayedLeaveFromWidgetResponseData,
IRtcLivekitGetTokenFromWidgetActionRequest,
IRtcLivekitGetTokenFromWidgetResponseData,
} from "./interfaces/RtcLivekitActions";
import { IThemeChangeActionRequestData } from "./interfaces/ThemeChangeAction";
import { IUpdateStateToWidgetRequestData } from "./interfaces/UpdateStateAction";
import { IToDeviceMessage } from "./interfaces/IToDeviceMessage";
/**
* API handler for the client side of widgets. This raises events
* for each action received as `action:${action}` (eg: "action:screenshot").
* Default handling can be prevented by using preventDefault() on the
* raised event. The default handling varies for each action: ones
* which the SDK can handle safely are acknowledged appropriately and
* ones which are unhandled (custom or require the client to do something)
* are rejected with an error.
*
* Events which are preventDefault()ed must reply using the transport.
* The events raised will have a default of an IWidgetApiRequest
* interface.
*
* When the ClientWidgetApi is ready to start sending requests, it will
* raise a "ready" CustomEvent. After the ready event fires, actions can
* be sent and the transport will be ready.
*
* When the widget has indicated it has loaded, this class raises a
* "preparing" CustomEvent. The preparing event does not indicate that
* the widget is ready to receive communications - that is signified by
* the ready event exclusively.
*
* This class only handles one widget at a time.
*/
export class ClientWidgetApi extends EventEmitter {
public readonly transport: ITransport;
private cachedWidgetVersions: ApiVersion[] | null = null;
// contentLoadedActionSent is used to check that only one ContentLoaded request is send.
private contentLoadedActionSent = false;
private readonly allowedCapabilities = new Set<Capability>();
private readonly allowedEvents: WidgetEventCapability[] = [];
private isStopped = false;
private turnServers: AsyncGenerator<ITurnServer> | null = null;
private contentLoadedWaitTimer?: ReturnType<typeof setTimeout>;
// Stores pending requests to push a room's state to the widget
private readonly pushRoomStateTasks = new Set<Promise<void>>();
// Room ID → event type → state key → events to be pushed
private readonly pushRoomStateResult = new Map<string, Map<string, Map<string, IRoomEvent>>>();
private flushRoomStateTask: Promise<void> | null = null;
/**
* Creates a new client widget API. This will instantiate the transport
* and start everything. When the iframe is loaded under the widget's
* conditions, a "ready" event will be raised.
* @param {Widget} widget The widget to communicate with.
* @param {HTMLIFrameElement} iframe The iframe the widget is in.
* @param {WidgetDriver} driver The driver for this widget/client.
*/
public constructor(
public readonly widget: Widget,
iframe: HTMLIFrameElement,
private readonly driver: WidgetDriver,
) {
super();
if (!iframe?.contentWindow) {
throw new Error("No iframe supplied");
}
if (!widget) {
throw new Error("Invalid widget");
}
if (!driver) {
throw new Error("Invalid driver");
}
this.transport = new PostmessageTransport(
WidgetApiDirection.ToWidget,
widget.id,
iframe.contentWindow,
globalThis,
);
this.transport.targetOrigin = widget.origin;
this.transport.on("message", this.handleMessage.bind(this));
iframe.addEventListener("load", this.onIframeLoad.bind(this));
this.transport.start();
}
public hasCapability(capability: Capability): boolean {
return this.allowedCapabilities.has(capability);
}
public canUseRoomTimeline(roomId: string | Symbols.AnyRoom): boolean {
return (
this.hasCapability(`org.matrix.msc2762.timeline:${Symbols.AnyRoom}`) ||
this.hasCapability(`org.matrix.msc2762.timeline:${roomId}`)
);
}
public canSendRoomEvent(eventType: string, msgtype: string | null = null): boolean {
return this.allowedEvents.some((e) => e.matchesAsRoomEvent(EventDirection.Send, eventType, msgtype));
}
public canSendStateEvent(eventType: string, stateKey: string): boolean {
return this.allowedEvents.some((e) => e.matchesAsStateEvent(EventDirection.Send, eventType, stateKey));
}
public canSendToDeviceEvent(eventType: string): boolean {
return this.allowedEvents.some((e) => e.matchesAsToDeviceEvent(EventDirection.Send, eventType));
}
public canReceiveRoomEvent(eventType: string, msgtype: string | null = null): boolean {
return this.allowedEvents.some((e) => e.matchesAsRoomEvent(EventDirection.Receive, eventType, msgtype));
}
public canReceiveStateEvent(eventType: string, stateKey: string | null): boolean {
return this.allowedEvents.some((e) => e.matchesAsStateEvent(EventDirection.Receive, eventType, stateKey));
}
public canReceiveToDeviceEvent(eventType: string): boolean {
return this.allowedEvents.some((e) => e.matchesAsToDeviceEvent(EventDirection.Receive, eventType));
}
public canReceiveRoomAccountData(eventType: string): boolean {
return this.allowedEvents.some((e) => e.matchesAsRoomAccountData(EventDirection.Receive, eventType));
}
public stop(): void {
this.isStopped = true;
this.transport.stop();
}
public async getWidgetVersions(): Promise<ApiVersion[]> {
if (Array.isArray(this.cachedWidgetVersions)) {
return this.cachedWidgetVersions;
}
try {
const r = await this.transport.send<IWidgetApiRequestEmptyData, ISupportedVersionsActionResponseData>(
WidgetApiToWidgetAction.SupportedApiVersions,
{},
);
this.cachedWidgetVersions = r.supported_versions;
return r.supported_versions;
} catch (e) {
console.warn("non-fatal error getting supported widget versions: ", e);
return [];
}
}
private beginCapabilities(): void {
// widget has loaded - tell all the listeners that
this.emit("preparing");
let requestedCaps: Capability[];
this.transport
.send<IWidgetApiRequestEmptyData, ICapabilitiesActionResponseData>(WidgetApiToWidgetAction.Capabilities, {})
.then((caps) => {
requestedCaps = caps.capabilities;
return this.driver.validateCapabilities(new Set(caps.capabilities));
})
.then((allowedCaps) => {
this.allowCapabilities([...allowedCaps], requestedCaps);
this.emit("ready");
})
.catch((e) => {
this.emit("error:preparing", e);
});
}
private allowCapabilities(allowed: string[], requested: string[]): void {
console.log(`Widget ${this.widget.id} is allowed capabilities:`, allowed);
for (const c of allowed) this.allowedCapabilities.add(c);
const allowedEvents = WidgetEventCapability.findEventCapabilities(allowed);
this.allowedEvents.push(...allowedEvents);
this.transport
.send(WidgetApiToWidgetAction.NotifyCapabilities, <INotifyCapabilitiesActionRequestData>{
requested,
approved: Array.from(this.allowedCapabilities),
})
.catch((e) => {
console.warn("non-fatal error notifying widget of approved capabilities:", e);
})
.then(() => {
this.emit("capabilitiesNotified");
});
// Push the initial room state for all rooms with a timeline capability
for (const c of allowed) {
if (isTimelineCapability(c)) {
const roomId = getTimelineRoomIDFromCapability(c);
if (roomId === Symbols.AnyRoom) {
for (const roomId of this.driver.getKnownRooms()) {
this.pushRoomState(roomId);
}
} else {
this.pushRoomState(roomId);
}
}
}
if (allowed.includes(MatrixCapabilities.MSC4407ReceiveStickyEvent)) {
console.debug(`Widget ${this.widget.id} is allowed to receive sticky events, check current sticky state.`);
// If the widget can receive sticky events, push all sticky events in known rooms now.
// Sticky events are like a state, and passed history is needed to get the full state.
const roomIds = allowed
.filter((capability) => isTimelineCapability(capability))
.map((timelineCapability) => getTimelineRoomIDFromCapability(timelineCapability))
.flatMap((roomIdOrWildcard) => {
if (roomIdOrWildcard === Symbols.AnyRoom) {
// Do we support getting sticky state for any room?
return this.driver.getKnownRooms();
} else {
return roomIdOrWildcard;
}
});
console.debug(`Widget ${this.widget.id} is allowed to receive sticky events in rooms:`, roomIds);
for (const roomId of roomIds) {
this.pushStickyState(roomId).catch((err) => {
console.error(`Failed to push sticky events to widget ${this.widget.id} for room ${roomId}:`, err);
});
}
}
// If new events are allowed and the currently viewed room isn't covered
// by a timeline capability, then we know that there could be some state
// in the viewed room that the widget hasn't learned about yet- push it.
if (allowedEvents.length > 0 && this.viewedRoomId !== null && !this.canUseRoomTimeline(this.viewedRoomId)) {
this.pushRoomState(this.viewedRoomId);
}
}
private onIframeLoad(ev: Event): void {
if (this.widget.waitForIframeLoad) {
// If the widget is set to waitForIframeLoad the capabilities immediately get setup after load.
// The client does not wait for the ContentLoaded action.
this.beginCapabilities();
} else {
// Reaching this means, that the Iframe got reloaded/loaded and
// the clientApi is awaiting the FIRST ContentLoaded action.
console.log("waitForIframeLoad is false: waiting for widget to send contentLoaded");
this.contentLoadedWaitTimer = setTimeout(() => {
console.error(
"Widget specified waitForIframeLoad=false but timed out waiting for contentLoaded event!",
);
}, 10000);
this.contentLoadedActionSent = false;
}
}
private handleContentLoadedAction(action: IContentLoadedActionRequest): void {
if (this.contentLoadedWaitTimer !== undefined) {
clearTimeout(this.contentLoadedWaitTimer);
this.contentLoadedWaitTimer = undefined;
}
if (this.contentLoadedActionSent) {
throw new Error(
"Improper sequence: ContentLoaded Action can only be sent once after the widget loaded " +
"and should only be used if waitForIframeLoad is false (default=true)",
);
}
if (this.widget.waitForIframeLoad) {
this.transport.reply(action, <IWidgetApiErrorResponseData>{
error: {
message:
"Improper sequence: not expecting ContentLoaded event if " +
"waitForIframeLoad is true (default=true)",
},
});
} else {
this.transport.reply(action, <IWidgetApiRequestEmptyData>{});
this.beginCapabilities();
}
this.contentLoadedActionSent = true;
}
private replyVersions(request: ISupportedVersionsActionRequest): void {
this.transport.reply<ISupportedVersionsActionResponseData>(request, {
supported_versions: CurrentApiVersions,
});
}
private async supportsUpdateState(): Promise<boolean> {
return (await this.getWidgetVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE);
}
private handleCapabilitiesRenegotiate(request: IRenegotiateCapabilitiesActionRequest): void {
// acknowledge first
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
const requested = request.data?.capabilities || [];
const newlyRequested = new Set(requested.filter((r) => !this.hasCapability(r)));
if (newlyRequested.size === 0) {
// Nothing to do - skip validation
this.allowCapabilities([], []);
}
this.driver
.validateCapabilities(newlyRequested)
.then((allowed) => this.allowCapabilities([...allowed], [...newlyRequested]));
}
private handleNavigate(request: INavigateActionRequest): void {
if (!this.hasCapability(MatrixCapabilities.MSC2931Navigate)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}
if (!request.data?.uri.startsWith("https://matrix.to/#")) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid matrix.to URI" },
});
}
const onErr = (e: unknown): void => {
console.error("[ClientWidgetApi] Failed to handle navigation: ", e);
this.handleDriverError(e, request, "Error handling navigation");
};
try {
this.driver
.navigate(request.data.uri.toString())
.catch((e: unknown) => onErr(e))
.then(() => {
return this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
});
} catch (e) {
return onErr(e);
}
}
private handleOIDC(request: IGetOpenIDActionRequest): void {
let phase = 1; // 1 = initial request, 2 = after user manual confirmation
const replyState = (
state: OpenIDRequestState,
credential?: IOpenIDCredentials,
): void | Promise<IWidgetApiAcknowledgeResponseData> => {
credential = credential || {};
if (phase > 1) {
return this.transport.send<IOpenIDCredentialsActionRequestData>(
WidgetApiToWidgetAction.OpenIDCredentials,
{
state: state,
original_request_id: request.requestId,
...credential,
},
);
} else {
return this.transport.reply<IGetOpenIDActionResponseData>(request, {
state: state,
...credential,
});
}
};
const replyError = (msg: string): void | Promise<IWidgetApiAcknowledgeResponseData> => {
console.error("[ClientWidgetApi] Failed to handle OIDC: ", msg);
if (phase > 1) {
// We don't have a way to indicate that a random error happened in this flow, so
// just block the attempt.
return replyState(OpenIDRequestState.Blocked);
} else {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: msg },
});
}
};
const observer = new SimpleObservable<IOpenIDUpdate>((update) => {
if (update.state === OpenIDRequestState.PendingUserConfirmation && phase > 1) {
observer.close();
return replyError("client provided out-of-phase response to OIDC flow");
}
if (update.state === OpenIDRequestState.PendingUserConfirmation) {
replyState(update.state);
phase++;
return;
}
if (update.state === OpenIDRequestState.Allowed && !update.token) {
return replyError("client provided invalid OIDC token for an allowed request");
}
if (update.state === OpenIDRequestState.Blocked) {
update.token = undefined; // just in case the client did something weird
}
observer.close();
return replyState(update.state, update.token);
});
this.driver.askOpenID(observer);
}
private handleReadRoomAccountData(request: IReadRoomAccountDataFromWidgetActionRequest): void | Promise<void> {
const events = this.driver.readRoomAccountData(request.data.type);
if (!this.canReceiveRoomAccountData(request.data.type)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Cannot read room account data of this type" },
});
}
return events.then((evs) => {
this.transport.reply<IReadRoomAccountDataFromWidgetResponseData>(request, { events: evs });
});
}
private async handleReadEvents(request: IReadEventFromWidgetActionRequest): Promise<void> {
if (!request.data.type) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - missing event type" },
});
}
if (request.data.limit !== undefined && (!request.data.limit || request.data.limit < 0)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - limit out of range" },
});
}
let askRoomIds: string[];
if (request.data.room_ids === undefined) {
askRoomIds = this.viewedRoomId === null ? [] : [this.viewedRoomId];
} else if (request.data.room_ids === Symbols.AnyRoom) {
askRoomIds = this.driver.getKnownRooms().filter((roomId) => this.canUseRoomTimeline(roomId));
} else {
askRoomIds = request.data.room_ids;
for (const roomId of askRoomIds) {
if (!this.canUseRoomTimeline(roomId)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: `Unable to access room timeline: ${roomId}` },
});
}
}
}
const limit = request.data.limit || 0;
const since = request.data.since;
let stateKey: string | undefined = undefined;
let msgtype: string | undefined = undefined;
if (request.data.state_key !== undefined) {
stateKey = request.data.state_key === true ? undefined : request.data.state_key.toString();
if (!this.canReceiveStateEvent(request.data.type, stateKey ?? null)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Cannot read state events of this type" },
});
}
} else {
msgtype = request.data.msgtype;
if (!this.canReceiveRoomEvent(request.data.type, msgtype)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Cannot read room events of this type" },
});
}
}
let events: IRoomEvent[];
if (request.data.room_ids === undefined && askRoomIds.length === 0) {
// For backwards compatibility we still call the deprecated
// readRoomEvents and readStateEvents methods in case the client isn't
// letting us know the currently viewed room via setViewedRoomId
//
// This can be considered as a deprecated implementation.
// A driver should call `setViewedRoomId` on the widget messaging and implement the new readRoomState and readRoomTimeline
// Methods.
// This block makes sure that it is also possible to not use setViewedRoomId.
// readRoomTimeline and readRoomState are required however! Otherwise widget requests that include
// `room_ids` will fail.
console.warn(
"The widgetDriver uses deprecated behaviour:\n It does not set the viewedRoomId using `setViewedRoomId`",
);
events = await // This returns [] with the current driver of Element Web.
// Add default implementations of the `readRoomEvents` and `readStateEvents`
// methods to use `readRoomTimeline` and `readRoomState` if they are not overwritten.
(request.data.state_key === undefined
? this.driver.readRoomEvents(request.data.type, msgtype, limit, null, since)
: this.driver.readStateEvents(request.data.type, stateKey, limit, null));
} else if (await this.supportsUpdateState()) {
// Calling read_events with a stateKey still reads from the rooms timeline (not the room state).
events = (
await Promise.all(
askRoomIds.map((roomId) =>
this.driver.readRoomTimeline(roomId, request.data.type, msgtype, stateKey, limit, since),
),
)
).flat(1);
} else {
// TODO: remove this once `UnstableApiVersion.MSC2762_UPDATE_STATE` becomes stable.
// Before version `MSC2762_UPDATE_STATE` we used readRoomState for read_events actions.
events = (
request.data.state_key === undefined
? await Promise.all(
askRoomIds.map((roomId) =>
this.driver.readRoomTimeline(roomId, request.data.type, msgtype, stateKey, limit, since),
),
)
: await Promise.all(
askRoomIds.map((roomId) => this.driver.readRoomState(roomId, request.data.type, stateKey)),
)
).flat(1);
}
this.transport.reply<IReadEventFromWidgetResponseData>(request, { events });
}
private handleSendEvent(request: ISendEventFromWidgetActionRequest): void {
if (!request.data.type) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - missing event type" },
});
}
if (!!request.data.room_id && !this.canUseRoomTimeline(request.data.room_id)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: `Unable to access room timeline: ${request.data.room_id}` },
});
}
if (request.data.parent_delay_id !== undefined) {
console.warn("Data includes parent_delay_id, but the widgetDriver ignores it");
}
const delay = request.data.delay;
if (delay !== undefined && !this.hasCapability(MatrixCapabilities.MSC4157SendDelayedEvent)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: `Missing capability for ${MatrixCapabilities.MSC4157SendDelayedEvent}` },
});
}
const isStickyEvent = request.data.sticky_duration_ms !== undefined;
if (isStickyEvent && !this.hasCapability(MatrixCapabilities.MSC4407SendStickyEvent)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: `Missing capability for ${MatrixCapabilities.MSC4407SendStickyEvent}` },
});
}
let sendEventPromise: Promise<ISendEventDetails | ISendDelayedEventDetails>;
if (request.data.state_key !== undefined) {
if (!this.canSendStateEvent(request.data.type, request.data.state_key)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Cannot send state events of this type" },
});
}
if (isStickyEvent) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Cannot send a state event with a sticky duration" },
});
}
if (delay !== undefined) {
sendEventPromise = this.driver.sendDelayedEvent(
delay,
request.data.type,
request.data.content || {},
request.data.state_key,
request.data.room_id,
);
} else {
sendEventPromise = this.driver.sendEvent(
request.data.type,
request.data.content || {},
request.data.state_key,
request.data.room_id,
);
}
} else {
const content = (request.data.content as { msgtype?: string }) || {};
const msgtype = content["msgtype"];
if (!this.canSendRoomEvent(request.data.type, msgtype)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Cannot send room events of this type" },
});
}
// Events can be sticky, delayed, both, or neither. The following
// section of code takes the common parameters and uses the correct
// function depending on the request type.
const params: Parameters<WidgetDriver["sendEvent"]> = [
request.data.type,
content,
null, // not sending a state event
request.data.room_id,
];
if (delay !== undefined && request.data.sticky_duration_ms) {
sendEventPromise = this.driver.sendDelayedStickyEvent(
delay,
request.data.sticky_duration_ms,
request.data.type,
content,
request.data.room_id,
);
} else if (delay !== undefined) {
sendEventPromise = this.driver.sendDelayedEvent(delay, ...params);
} else if (request.data.sticky_duration_ms) {
sendEventPromise = this.driver.sendStickyEvent(
request.data.sticky_duration_ms,
request.data.type,
content,
request.data.room_id,
);
} else {
sendEventPromise = this.driver.sendEvent(...params);
}
}
sendEventPromise
.then((sentEvent) => {
return this.transport.reply<ISendEventFromWidgetResponseData>(request, {
room_id: sentEvent.roomId,
...("eventId" in sentEvent
? {
event_id: sentEvent.eventId,
}
: {
delay_id: sentEvent.delayId,
}),
});
})
.catch((e: unknown) => {
console.error("error sending event: ", e);
this.handleDriverError(e, request, "Error sending event");
});
}
private handleUpdateDelayedEvent(request: IUpdateDelayedEventFromWidgetActionRequest): void {
if (!request.data.delay_id) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - missing delay_id" },
});
}
if (!this.hasCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}
let updateDelayedEvent: (delayId: string) => Promise<void>;
switch (request.data.action) {
case UpdateDelayedEventAction.Cancel:
updateDelayedEvent = this.driver.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Restart:
updateDelayedEvent = this.driver.restartScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Send:
updateDelayedEvent = this.driver.sendScheduledDelayedEvent;
break;
default:
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - unsupported action" },
});
}
updateDelayedEvent
.call(this.driver, request.data.delay_id)
.then(() => {
return this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
})
.catch((e: unknown) => {
console.error("error updating delayed event: ", e);
this.handleDriverError(e, request, "Error updating delayed event");
});
}
private async handleSendToDevice(request: ISendToDeviceFromWidgetActionRequest): Promise<void> {
if (!request.data.type) {
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - missing event type" },
});
} else if (!request.data.messages) {
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - missing event contents" },
});
} else if (typeof request.data.encrypted !== "boolean") {
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - missing encryption flag" },
});
} else if (!this.canSendToDeviceEvent(request.data.type)) {
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Cannot send to-device events of this type" },
});
} else {
try {
await this.driver.sendToDevice(request.data.type, request.data.encrypted, request.data.messages);
this.transport.reply<ISendToDeviceFromWidgetResponseData>(request, {});
} catch (e) {
console.error("error sending to-device event", e);
this.handleDriverError(e, request, "Error sending event");
}
}
}
private async pollTurnServers(turnServers: AsyncGenerator<ITurnServer>, initialServer: ITurnServer): Promise<void> {
try {
await this.transport.send<IUpdateTurnServersRequestData>(
WidgetApiToWidgetAction.UpdateTurnServers,
initialServer as IUpdateTurnServersRequestData, // it's compatible, but missing the index signature
);
// Pick the generator up where we left off
for await (const server of turnServers) {
await this.transport.send<IUpdateTurnServersRequestData>(
WidgetApiToWidgetAction.UpdateTurnServers,
server as IUpdateTurnServersRequestData, // it's compatible, but missing the index signature
);
}
} catch (e) {
console.error("error polling for TURN servers", e);
}
}
private async handleWatchTurnServers(request: IWatchTurnServersRequest): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) {
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
} else if (this.turnServers) {
// We're already polling, so this is a no-op
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
} else {
try {
const turnServers = this.driver.getTurnServers();
// Peek at the first result, so we can at least verify that the
// client isn't banned from getting TURN servers entirely
const { done, value } = await turnServers.next();
if (done) throw new Error("Client refuses to provide any TURN servers");
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
// Start the poll loop, sending the widget the initial result
this.pollTurnServers(turnServers, value);
this.turnServers = turnServers;
} catch (e) {
console.error("error getting first TURN server results", e);
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "TURN servers not available" },
});
}
}
}
private async handleUnwatchTurnServers(request: IUnwatchTurnServersRequest): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) {
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
} else if (!this.turnServers) {
// We weren't polling anyways, so this is a no-op
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
} else {
// Stop the generator, allowing it to clean up
await this.turnServers.return(undefined);
this.turnServers = null;
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
}
}
private async handleReadRelations(request: IReadRelationsFromWidgetActionRequest): Promise<void> {
if (!request.data.event_id) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - missing event ID" },
});
}
if (request.data.limit !== undefined && request.data.limit < 0) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - limit out of range" },
});
}
if (request.data.room_id !== undefined && !this.canUseRoomTimeline(request.data.room_id)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: `Unable to access room timeline: ${request.data.room_id}` },
});
}
try {
const result = await this.driver.readEventRelations(
request.data.event_id,
request.data.room_id,
request.data.rel_type,
request.data.event_type,
request.data.from,
request.data.to,
request.data.limit,
request.data.direction,
);
// only return events that the user has the permission to receive
const chunk = result.chunk.filter((e) => {
if (e.state_key !== undefined) {
return this.canReceiveStateEvent(e.type, e.state_key);
} else {
return this.canReceiveRoomEvent(e.type, (e.content as { msgtype?: string })["msgtype"]);
}
});
return this.transport.reply<IReadRelationsFromWidgetResponseData>(request, {
chunk,
prev_batch: result.prevBatch,
next_batch: result.nextBatch,
});
} catch (e) {
console.error("error getting the relations", e);
this.handleDriverError(e, request, "Unexpected error while reading relations");
}
}
private async handleUserDirectorySearch(request: IUserDirectorySearchFromWidgetActionRequest): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC3973UserDirectorySearch)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}
if (typeof request.data.search_term !== "string") {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - missing search term" },
});
}
if (request.data.limit !== undefined && request.data.limit < 0) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Invalid request - limit out of range" },
});
}
try {
const result = await this.driver.searchUserDirectory(request.data.search_term, request.data.limit);
return this.transport.reply<IUserDirectorySearchFromWidgetResponseData>(request, {
limited: result.limited,
results: result.results.map((r) => ({
user_id: r.userId,
display_name: r.displayName,
avatar_url: r.avatarUrl,
})),
});
} catch (e) {
console.error("error searching in the user directory", e);
this.handleDriverError(e, request, "Unexpected error while searching in the user directory");
}
}
private async handleGetMediaConfig(request: IGetMediaConfigActionFromWidgetActionRequest): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC4039UploadFile)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}
try {
const result = await this.driver.getMediaConfig();
return this.transport.reply<IGetMediaConfigActionFromWidgetResponseData>(request, result);
} catch (e) {
console.error("error while getting the media configuration", e);
this.handleDriverError(e, request, "Unexpected error while getting the media configuration");
}
}
private async handleRtcTransports(request: IRtcTransportsFromWidgetActionRequest): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC4515RtcTransports)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}
try {
const result = await this.driver.getRtcTransports();
return this.transport.reply<IRtcTransportsFromWidgetResponseData>(request, {
rtc_transports: result.rtc_transports,
});
} catch (e) {
console.error("error while getting the RTC transports", e);
this.handleDriverError(e, request, "Unexpected error while getting the RTC transports");
}
}
private async handleUploadFile(request: IUploadFileActionFromWidgetActionRequest): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC4039UploadFile)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}
try {
const result = await this.driver.uploadFile(request.data.file);
return this.transport.reply<IUploadFileActionFromWidgetResponseData>(request, {
content_uri: result.contentUri,
});
} catch (e) {
console.error("error while uploading a file", e);
this.handleDriverError(e, request, "Unexpected error while uploading a file");
}
}
private async handleDownloadFile(request: IDownloadFileActionFromWidgetActionRequest): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC4039DownloadFile)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}
try {
const result = await this.driver.downloadFile(request.data.content_uri);
return this.transport.reply<IDownloadFileActionFromWidgetResponseData>(request, { file: result.file });
} catch (e) {
console.error("error while downloading a file", e);
this.handleDriverError(e, request, "Unexpected error while downloading a file");
}
}
private async handleRtcLivekitGetToken(request: IRtcLivekitGetTokenFromWidgetActionRequest): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC4533RtcLivekitGetToken)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}
try {
const result = await this.driver.getRtcLivekitToken(request.data);
return this.transport.reply<IRtcLivekitGetTokenFromWidgetResponseData>(request, result);
} catch (e) {
console.error("error while getting a LiveKit token", e);
this.handleDriverError(e, request, "Unexpected error while getting a LiveKit token");
}
}
private async handleRtcLivekitDelegateDelayedLeave(
request: IRtcLivekitDelegateDelayedLeaveFromWidgetActionRequest,
): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC4533RtcLivekitDelegateDelayedLeave)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}
try {
const result = await this.driver.delegateRtcLivekitDelayedLeave(request.data);
return this.transport.reply<IRtcLivekitDelegateDelayedLeaveFromWidgetResponseData>(request, result);
} catch (e) {
console.error("error while delegating a LiveKit delayed leave", e);
this.handleDriverError(e, request, "Unexpected error while delegating a LiveKit delayed leave");
}
}
private handleDriverError(e: unknown, request: IWidgetApiRequest, message: string): void {
const data = this.driver.processError(e);
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: {
message,
...data,
},
});
}
private handleMessage(ev: CustomEvent<IWidgetApiRequest>): void | Promise<void> {
if (this.isStopped) return;
const actionEv = new CustomEvent(`action:${ev.detail.action}`, {
detail: ev.detail,
cancelable: true,
});
this.emit(`action:${ev.detail.action}`, actionEv);
if (!actionEv.defaultPrevented) {
switch (ev.detail.action) {
case WidgetApiFromWidgetAction.ContentLoaded:
return this.handleContentLoadedAction(<IContentLoadedActionRequest>ev.detail);
case WidgetApiFromWidgetAction.SupportedApiVersions:
return this.replyVersions(<ISupportedVersionsActionRequest>ev.detail);
case WidgetApiFromWidgetAction.SendEvent:
return this.handleSendEvent(<ISendEventFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.SendToDevice:
return this.handleSendToDevice(<ISendToDeviceFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.GetOpenIDCredentials:
return this.handleOIDC(<IGetOpenIDActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC2931Navigate:
return this.handleNavigate(<INavigateActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC2974RenegotiateCapabilities:
return this.handleCapabilitiesRenegotiate(<IRenegotiateCapabilitiesActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC2876ReadEvents:
return this.handleReadEvents(<IReadEventFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.WatchTurnServers:
return this.handleWatchTurnServers(<IWatchTurnServersRequest>ev.detail);
case WidgetApiFromWidgetAction.UnwatchTurnServers:
return this.handleUnwatchTurnServers(<IUnwatchTurnServersRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC3869ReadRelations:
return this.handleReadRelations(<IReadRelationsFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC3973UserDirectorySearch:
return this.handleUserDirectorySearch(<IUserDirectorySearchFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.BeeperReadRoomAccountData:
return this.handleReadRoomAccountData(<IReadRoomAccountDataFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction:
return this.handleGetMediaConfig(<IGetMediaConfigActionFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4515GetRtcTransports:
return this.handleRtcTransports(<IRtcTransportsFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4039UploadFileAction:
return this.handleUploadFile(<IUploadFileActionFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4039DownloadFileAction:
return this.handleDownloadFile(<IDownloadFileActionFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent:
return this.handleUpdateDelayedEvent(<IUpdateDelayedEventFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4533RtcLivekitGetToken:
return this.handleRtcLivekitGetToken(<IRtcLivekitGetTokenFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4533RtcLivekitDelegateDelayedLeave:
return this.handleRtcLivekitDelegateDelayedLeave(
<IRtcLivekitDelegateDelayedLeaveFromWidgetActionRequest>ev.detail,
);
default:
return this.transport.reply(ev.detail, <IWidgetApiErrorResponseData>{
error: {
message: "Unknown or unsupported from-widget action: " + ev.detail.action,
},
});
}
}
}
/**
* Informs the widget that the client's theme has changed.
* @param theme The theme data, as an object with arbitrary contents.
*/
public updateTheme(theme: IThemeChangeActionRequestData): Promise<IWidgetApiResponseData> {
return this.transport.send(WidgetApiToWidgetAction.ThemeChange, theme);
}
/**
* Informs the widget that the client's language has changed.
* @param lang The BCP 47 identifier representing the client's current language.
*/
public updateLanguage(lang: string): Promise<IWidgetApiResponseData> {
return this.transport.send(WidgetApiToWidgetAction.LanguageChange, { lang });
}
/**
* Takes a screenshot of the widget.
* @returns Resolves to the widget's screenshot.
* @throws Throws if there is a problem.
*/
public takeScreenshot(): Promise<IScreenshotActionResponseData> {
return this.transport.send(WidgetApiToWidgetAction.TakeScreenshot, <IWidgetApiRequestEmptyData>{});
}
/**
* Alerts the widget to whether or not it is currently visible.
* @param {boolean} isVisible Whether the widget is visible or not.
* @returns {Promise<IWidgetApiResponseData>} Resolves when the widget acknowledges the update.
*/
public updateVisibility(isVisible: boolean): Promise<IWidgetApiResponseData> {
return this.transport.send(WidgetApiToWidgetAction.UpdateVisibility, <IVisibilityActionRequestData>{
visible: isVisible,
});
}
public sendWidgetConfig(data: IModalWidgetOpenRequestData): Promise<void> {
return this.transport.send<IModalWidgetOpenRequestData>(WidgetApiToWidgetAction.WidgetConfig, data).then();
}
public notifyModalWidgetButtonClicked(id: IModalWidgetOpenRequestDataButton["id"]): Promise<void> {
return this.transport
.send<IModalWidgetButtonClickedRequestData>(WidgetApiToWidgetAction.ButtonClicked, { id })
.then();
}
public notifyModalWidgetClose(data: IModalWidgetReturnData): Promise<void> {
return this.transport.send<IModalWidgetReturnData>(WidgetApiToWidgetAction.CloseModalWidget, data).then();
}
/**
* Feeds an event to the widget. As a client you are expected to call this
* for every new event in every room to which you are joined or invited.
* @param {IRoomEvent} rawEvent The event to (try to) send to the widget.
* @param {string} currentViewedRoomId The room ID the user is currently
* interacting with. Not the room ID of the event.
* @returns {Promise<void>} Resolves when delivered or if the widget is not
* able to read the event due to permissions, rejects if the widget failed
* to handle the event.
* @deprecated It is recommended to communicate the viewed room ID by calling
* {@link ClientWidgetApi.setViewedRoomId} rather than passing it to this
* method.
*/
public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise<void>;
/**
* Feeds an event to the widget. As a client you are expected to call this
* for every new event (including state events) in every room to which you are joined or invited.
* @param {IRoomEvent} rawEvent The event to (try to) send to the widget.
* @returns {Promise<void>} Resolves when delivered or if the widget is not
* able to read the event due to permissions, rejects if the widget failed
* to handle the event.
*/
public async feedEvent(rawEvent: IRoomEvent): Promise<void>;
public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId?: string): Promise<void> {
if (currentViewedRoomId !== undefined) this.setViewedRoomId(currentViewedRoomId);
if (rawEvent.room_id !== this.viewedRoomId && !this.canUseRoomTimeline(rawEvent.room_id)) {
return; // no-op
}
if (rawEvent.state_key !== undefined && rawEvent.state_key !== null) {
// state event
if (!this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)) {
return; // no-op
}
} else {
// message event
if (!this.canReceiveRoomEvent(rawEvent.type, (rawEvent.content as { msgtype?: string })?.["msgtype"])) {
return; // no-op
}
}
// Feed the event into the widget
await this.transport.send<ISendEventToWidgetRequestData>(
WidgetApiToWidgetAction.SendEvent,
// it's compatible, but missing the index signature
rawEvent as ISendEventToWidgetRequestData,
);
}
/**
* Feeds a to-device event to the widget. As a client you are expected to
* call this for every to-device event you receive.
* @param {IRoomEvent} rawEvent The event to (try to) send to the widget.
* @param {boolean} encrypted Whether the event contents were encrypted.
* @returns {Promise<void>} Resolves when delivered or if the widget is not
* able to receive the event due to permissions, rejects if the widget
* failed to handle the event.
*/
public async feedToDevice(message: IToDeviceMessage, encrypted: boolean): Promise<void> {
if (this.canReceiveToDeviceEvent(message.type)) {
await this.transport.send<ISendToDeviceToWidgetRequestData>(WidgetApiToWidgetAction.SendToDevice, {
...message,
encrypted,
});
}
}
private viewedRoomId: string | null = null;
/**
* Indicate that a room is being viewed (making it possible for the widget
* to interact with it).
*/
public setViewedRoomId(roomId: string | null): void {
this.viewedRoomId = roomId;
// If the widget doesn't have timeline permissions for the room then
// this is its opportunity to learn the room state. We push the entire
// room state, which could be redundant if this room had been viewed
// once before, but it's easier than selectively pushing just the bits
// of state that changed while the room was in the background.
if (roomId !== null && !this.canUseRoomTimeline(roomId)) this.pushRoomState(roomId);
}
private async flushRoomState(): Promise<void> {
try {
// Only send a single action once all concurrent tasks have completed
do await Promise.all(this.pushRoomStateTasks);
while (this.pushRoomStateTasks.size > 0);
const events: IRoomEvent[] = [];
for (const eventTypeMap of this.pushRoomStateResult.values()) {
for (const stateKeyMap of eventTypeMap.values()) {
events.push(...stateKeyMap.values());
}
}
if ((await this.getWidgetVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE)) {
// Only send state updates when using UpdateState. Otherwise the SendEvent action will be responsible for state updates.
await this.transport.send<IUpdateStateToWidgetRequestData>(WidgetApiToWidgetAction.UpdateState, {
state: events,
});
}
} finally {
this.flushRoomStateTask = null;
}
}
/**
* Reads the current sticky state of the room and pushes it to the widget.
*
* It will only push events that the widget is allowed to receive.
* @param roomId
* @private
*/
private async pushStickyState(roomId: string): Promise<void> {
console.debug("Pushing sticky state to widget for room", roomId);
return this.driver
.readStickyEvents(roomId)
.then((events) => {
// filter to the allowed sticky events
const filtered = events.filter((e) => {
return this.canReceiveRoomEvent(
e.type,
typeof e.content?.msgtype === "string" ? e.content.msgtype : null,
);
});
return { roomId, stickyEvents: filtered };
})
.then(async ({ roomId, stickyEvents }) => {
console.debug("Pushing", stickyEvents.length, "sticky events to widget for room", roomId);
const promises = stickyEvents.map((rawEvent) => {
return this.transport.send<ISendEventToWidgetRequestData>(
WidgetApiToWidgetAction.SendEvent,
// copied from feedEvent; it's compatible, but missing the index signature
rawEvent as ISendEventToWidgetRequestData,
);
});
await Promise.all(promises);
});
}
/**
* Read the room's state and push all entries that the widget is allowed to
* read through to the widget.
*/
private pushRoomState(roomId: string): void {
for (const cap of this.allowedEvents) {
if (cap.kind === EventKind.State && cap.direction === EventDirection.Receive) {
// Initiate the task
const events = this.driver.readRoomState(roomId, cap.eventType, cap.keyStr ?? undefined);
const task = events
.then(
(events) => {
// When complete, queue the resulting events to be
// pushed to the widget
for (const event of events) {
let eventTypeMap = this.pushRoomStateResult.get(roomId);
if (eventTypeMap === undefined) {
eventTypeMap = new Map();
this.pushRoomStateResult.set(roomId, eventTypeMap);
}
let stateKeyMap = eventTypeMap.get(cap.eventType);
if (stateKeyMap === undefined) {
stateKeyMap = new Map();
eventTypeMap.set(cap.eventType, stateKeyMap);
}
if (!stateKeyMap.has(event.state_key!)) stateKeyMap.set(event.state_key!, event);
}
},
(e) =>
console.error(
`Failed to read room state for ${roomId} (${cap.eventType}, ${cap.keyStr})`,
e,
),
)
.then(() => {
// Mark request as no longer pending
this.pushRoomStateTasks.delete(task);
});
// Mark task as pending
this.pushRoomStateTasks.add(task);
// Assuming no other tasks are already happening concurrently,
// schedule the widget action that actually pushes the events
this.flushRoomStateTask ??= this.flushRoomState();
this.flushRoomStateTask.catch((e) => console.error("Failed to push room state", e));
}
}
}
/**
* Feeds a room state update to the widget. As a client you are expected to
* call this for every state update in every room to which you are joined or
* invited.
* @param {IRoomEvent} rawEvent The state event corresponding to the updated
* room state entry.
* @returns {Promise<void>} Resolves when delivered or if the widget is not
* able to receive the room state due to permissions, rejects if the
* widget failed to handle the update.
*/
public async feedStateUpdate(rawEvent: IRoomEvent): Promise<void> {
if (rawEvent.state_key === undefined) throw new Error("Not a state event");
if (
(rawEvent.room_id === this.viewedRoomId || this.canUseRoomTimeline(rawEvent.room_id)) &&
this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)
) {
// Updates could race with the initial push of the room's state
if (this.pushRoomStateTasks.size === 0) {
// No initial push tasks are pending; safe to send immediately
if ((await this.getWidgetVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE)) {
// Only send state updates when using UpdateState. Otherwise the SendEvent action will be responsible for state updates.
await this.transport.send<IUpdateStateToWidgetRequestData>(WidgetApiToWidgetAction.UpdateState, {
state: [rawEvent],
});
}
} else {
// Lump the update in with whatever data will be sent in the
// initial push later. Even if we set it to an "outdated" entry
// here, we can count on any newer entries being passed to this
// same method eventually; this won't cause stuck state.
let eventTypeMap = this.pushRoomStateResult.get(rawEvent.room_id);
if (eventTypeMap === undefined) {
eventTypeMap = new Map();
this.pushRoomStateResult.set(rawEvent.room_id, eventTypeMap);
}
let stateKeyMap = eventTypeMap.get(rawEvent.type);
if (stateKeyMap === undefined) {
stateKeyMap = new Map();
eventTypeMap.set(rawEvent.type, stateKeyMap);
}
if (!stateKeyMap.has(rawEvent.type)) stateKeyMap.set(rawEvent.state_key, rawEvent);
do await Promise.all(this.pushRoomStateTasks);
while (this.pushRoomStateTasks.size > 0);
await this.flushRoomStateTask;
}
}
}
}