Files
ThreadNet-Web/src/stores/widgets/StopGapWidget.ts
T

492 lines
20 KiB
TypeScript
Raw Normal View History

/*
2022-03-09 12:05:16 +00:00
* Copyright 2020 - 2022 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 { Room } from "matrix-js-sdk/src/models/room";
2020-09-29 15:09:52 -06:00
import {
ClientWidgetApi,
IModalWidgetOpenRequest,
IStickerActionRequest,
2020-09-29 15:09:52 -06:00
IStickyActionRequest,
2020-10-09 09:26:52 -06:00
ITemplateParams,
IWidget,
IWidgetApiErrorResponseData,
IWidgetApiRequest,
2020-09-29 15:09:52 -06:00
IWidgetApiRequestEmptyData,
IWidgetData,
MatrixCapabilities,
2020-10-09 09:26:52 -06:00
runTemplate,
Widget,
2020-09-30 20:49:31 -06:00
WidgetApiFromWidgetAction,
2020-11-19 11:24:17 -07:00
WidgetKind,
2020-09-29 15:09:52 -06:00
} from "matrix-widget-api";
import { EventEmitter } from "events";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
2021-10-22 17:23:32 -05:00
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/client";
2021-10-22 17:23:32 -05:00
import { _t } from "../../languageHandler";
2021-10-22 17:23:32 -05:00
import { StopGapWidgetDriver } from "./StopGapWidgetDriver";
import { WidgetMessagingStore } from "./WidgetMessagingStore";
import { RoomViewStore } from "../RoomViewStore";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { OwnProfileStore } from "../OwnProfileStore";
import WidgetUtils from '../../utils/WidgetUtils';
import { IntegrationManagers } from "../../integrations/IntegrationManagers";
import SettingsStore from "../../settings/SettingsStore";
import { WidgetType } from "../../widgets/WidgetType";
2020-09-29 15:09:52 -06:00
import ActiveWidgetStore from "../ActiveWidgetStore";
2020-09-30 16:12:00 -06:00
import { objectShallowClone } from "../../utils/objects";
import defaultDispatcher from "../../dispatcher/dispatcher";
2021-11-25 17:49:43 -03:00
import { Action } from "../../dispatcher/actions";
import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions";
2021-06-29 13:11:58 +01:00
import { ModalWidgetStore } from "../ModalWidgetStore";
2020-10-27 11:13:55 +00:00
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
2021-06-29 13:11:58 +01:00
import { getCustomTheme } from "../../theme";
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
2021-05-12 14:10:02 -06:00
import { ELEMENT_CLIENT_ID } from "../../identifiers";
import { getUserLanguage } from "../../languageHandler";
import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables";
import { arrayFastClone } from "../../utils/arrays";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import Modal from "../../Modal";
import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
// TODO: Destroy all of this code
interface IAppTileProps {
// Note: these are only the props we care about
app: IWidget;
room?: Room; // without a room it is a user widget
userId: string;
creatorUserId: string;
waitForIframeLoad: boolean;
2021-09-16 20:05:57 +02:00
whitelistCapabilities?: string[];
userWidget: boolean;
}
// TODO: Don't use this because it's wrong
export class ElementWidget extends Widget {
constructor(private rawDefinition: IWidget) {
super(rawDefinition);
}
public get templateUrl(): string {
if (WidgetType.JITSI.matches(this.type)) {
return WidgetUtils.getLocalJitsiWrapperUrl({
forLocalRender: true,
2020-10-19 21:13:31 +01:00
auth: super.rawData?.auth as string, // this.rawData can call templateUrl, do this to prevent looping
});
}
return super.templateUrl;
}
2020-10-13 14:39:27 -06:00
public get popoutTemplateUrl(): string {
if (WidgetType.JITSI.matches(this.type)) {
return WidgetUtils.getLocalJitsiWrapperUrl({
forLocalRender: false, // The only important difference between this and templateUrl()
2020-10-19 21:13:31 +01:00
auth: super.rawData?.auth as string,
2020-10-13 14:39:27 -06:00
});
}
return this.templateUrl; // use this instead of super to ensure we get appropriate templating
}
public get rawData(): IWidgetData {
let conferenceId = super.rawData['conferenceId'];
if (conferenceId === undefined) {
// we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
2020-10-09 09:26:52 -06:00
const parsedUrl = new URL(super.templateUrl); // use super to get the raw widget URL
conferenceId = parsedUrl.searchParams.get("confId");
}
2020-10-09 09:26:52 -06:00
let domain = super.rawData['domain'];
if (domain === undefined) {
// v1 widgets default to meet.element.io regardless of user settings
domain = "meet.element.io";
2020-10-09 09:26:52 -06:00
}
2020-10-27 11:13:55 +00:00
let theme = new ThemeWatcher().getEffectiveTheme();
if (theme.startsWith("custom-")) {
const customTheme = getCustomTheme(theme.slice(7));
2020-10-27 11:13:55 +00:00
// Jitsi only understands light/dark
theme = customTheme.is_dark ? "dark" : "light";
}
// only allow light/dark through, defaulting to dark as that was previously the only state
// accounts for legacy-light/legacy-dark themes too
if (theme.includes("light")) {
theme = "light";
} else {
theme = "dark";
}
return {
...super.rawData,
2020-10-27 11:13:55 +00:00
theme,
conferenceId,
2020-10-09 09:26:52 -06:00
domain,
};
}
2020-10-09 09:26:52 -06:00
public getCompleteUrl(params: ITemplateParams, asPopout = false): string {
2020-10-13 14:39:27 -06:00
return runTemplate(asPopout ? this.popoutTemplateUrl : this.templateUrl, {
...this.rawDefinition,
2020-10-09 09:26:52 -06:00
data: this.rawData,
}, params);
}
}
export class StopGapWidget extends EventEmitter {
private messaging: ClientWidgetApi;
2020-10-13 14:39:27 -06:00
private mockWidget: ElementWidget;
private scalarToken: string;
2020-11-12 10:36:30 -07:00
private roomId?: string;
2020-11-19 11:24:17 -07:00
private kind: WidgetKind;
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
constructor(private appTileProps: IAppTileProps) {
super();
2020-09-30 16:12:00 -06:00
let app = appTileProps.app;
// Backwards compatibility: not all old widgets have a creatorUserId
if (!app.creatorUserId) {
app = objectShallowClone(app); // clone to prevent accidental mutation
app.creatorUserId = MatrixClientPeg.get().getUserId();
}
this.mockWidget = new ElementWidget(app);
2020-11-12 10:36:30 -07:00
this.roomId = appTileProps.room?.roomId;
2020-11-19 11:24:17 -07:00
this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
2020-11-12 10:36:30 -07:00
}
private get eventListenerRoomId(): string {
// When widgets are listening to events, we need to make sure they're only
// receiving events for the right room. In particular, room widgets get locked
// to the room they were added in while account widgets listen to the currently
// active room.
if (this.roomId) return this.roomId;
return RoomViewStore.instance.getRoomId();
}
public get widgetApi(): ClientWidgetApi {
return this.messaging;
}
/**
* The URL to use in the iframe
*/
public get embedUrl(): string {
2021-06-29 13:11:58 +01:00
return this.runUrlTemplate({ asPopout: false });
2020-10-13 14:39:27 -06:00
}
/**
* The URL to use in the popout
*/
public get popoutUrl(): string {
2021-06-29 13:11:58 +01:00
return this.runUrlTemplate({ asPopout: true });
2020-10-13 14:39:27 -06:00
}
2021-06-29 13:11:58 +01:00
private runUrlTemplate(opts = { asPopout: false }): string {
const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {};
const defaults: ITemplateParams = {
2021-02-02 14:22:28 +00:00
widgetRoomId: this.roomId,
currentUserId: MatrixClientPeg.get().getUserId(),
userDisplayName: OwnProfileStore.instance.displayName,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
2021-05-12 14:10:02 -06:00
clientId: ELEMENT_CLIENT_ID,
clientTheme: SettingsStore.getValue("theme"),
clientLanguage: getUserLanguage(),
};
const templated = this.mockWidget.getCompleteUrl(Object.assign(defaults, fromCustomisation), opts?.asPopout);
const parsed = new URL(templated);
// Add in some legacy support sprinkles (for non-popout widgets)
// TODO: Replace these with proper widget params
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
if (!opts?.asPopout) {
parsed.searchParams.set('widgetId', this.mockWidget.id);
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
// Give the widget a scalar token if we're supposed to (more legacy)
// TODO: Stop doing this
if (this.scalarToken) {
parsed.searchParams.set('scalar_token', this.scalarToken);
}
}
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
// in HTTP, but URL parsers encode them anyways.
return parsed.toString().replace(/%24/g, '$');
}
public get isManagedByManager(): boolean {
return !!this.scalarToken;
}
public get started(): boolean {
return !!this.messaging;
}
2020-10-19 20:39:43 +01:00
private onOpenModal = async (ev: CustomEvent<IModalWidgetOpenRequest>) => {
ev.preventDefault();
if (ModalWidgetStore.instance.canOpenModalWidget()) {
ModalWidgetStore.instance.openModalWidget(ev.detail.data, this.mockWidget, this.roomId);
2020-10-19 20:39:43 +01:00
this.messaging.transport.reply(ev.detail, {}); // ack
} else {
this.messaging.transport.reply(ev.detail, {
error: {
message: "Unable to open modal at this time",
},
2021-06-29 13:11:58 +01:00
});
2020-10-19 20:39:43 +01:00
}
};
/**
* This starts the messaging for the widget if it is not in the state `started` yet.
* @param iframe the iframe the widget should use
*/
public startMessaging(iframe: HTMLIFrameElement): any {
if (this.started) return;
const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId);
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
2020-10-19 16:42:20 +01:00
this.messaging.on("preparing", () => this.emit("preparing"));
this.messaging.on("ready", () => this.emit("ready"));
this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
2020-10-19 20:39:43 +01:00
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging);
2020-09-29 15:09:52 -06:00
// Always attach a handler for ViewRoom, but permission check it internally
this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent<IViewRoomApiRequest>) => {
ev.preventDefault(); // stop the widget API from auto-rejecting this
// Check up front if this is even a valid request
const targetRoomId = (ev.detail.data || {}).room_id;
if (!targetRoomId) {
return this.messaging.transport.reply(ev.detail, <IWidgetApiErrorResponseData>{
2021-06-29 13:11:58 +01:00
error: { message: "Room ID not supplied." },
});
}
// Check the widget's permission
if (!this.messaging.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) {
return this.messaging.transport.reply(ev.detail, <IWidgetApiErrorResponseData>{
2021-06-29 13:11:58 +01:00
error: { message: "This widget does not have permission for this action (denied)." },
});
}
// at this point we can change rooms, so do that
defaultDispatcher.dispatch<ViewRoomPayload>({
2021-11-25 17:49:43 -03:00
action: Action.ViewRoom,
room_id: targetRoomId,
metricsTrigger: "Widget",
});
// acknowledge so the widget doesn't freak out
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
});
// Populate the map of "read up to" events for this widget with the current event in every room.
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
for (const room of MatrixClientPeg.get().getRooms()) {
// Timelines are most recent last
const events = room.getLiveTimeline()?.getEvents() || [];
const roomEvent = events[events.length - 1];
if (!roomEvent) continue; // force later code to think the room is fresh
this.readUpToMap[room.roomId] = roomEvent.getId();
}
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
MatrixClientPeg.get().on(ClientEvent.Event, this.onEvent);
MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.messaging.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
(ev: CustomEvent<IStickyActionRequest>) => {
if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
ActiveWidgetStore.instance.setWidgetPersistence(
this.mockWidget.id, this.roomId, ev.detail.data.value,
);
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
}
},
);
// TODO: Replace this event listener with appropriate driver functionality once the API
// establishes a sane way to send events back and forth.
this.messaging.on(`action:${WidgetApiFromWidgetAction.SendSticker}`,
(ev: CustomEvent<IStickerActionRequest>) => {
if (this.messaging.hasCapability(MatrixCapabilities.StickerSending)) {
// Acknowledge first
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
// Send the sticker
defaultDispatcher.dispatch({
action: 'm.sticker',
data: ev.detail.data,
widgetId: this.mockWidget.id,
});
}
},
);
if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) {
2020-10-19 16:42:20 +01:00
this.messaging.on(`action:${ElementWidgetActions.OpenIntegrationManager}`,
2020-09-30 16:12:00 -06:00
(ev: CustomEvent<IWidgetApiRequest>) => {
// Acknowledge first
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
// First close the stickerpicker
2021-06-29 13:11:58 +01:00
defaultDispatcher.dispatch({ action: "stickerpicker_close" });
2020-09-30 16:12:00 -06:00
// Now open the integration manager
// TODO: Spec this interaction.
const data = ev.detail.data;
2021-06-29 13:11:58 +01:00
const integType = data?.integType;
2020-09-30 16:12:00 -06:00
const integId = <string>data?.integId;
2022-05-06 12:46:26 -06:00
// noinspection JSIgnoredPromiseFromCall
IntegrationManagers.sharedInstance().getPrimaryManager().open(
MatrixClientPeg.get().getRoom(RoomViewStore.instance.getRoomId()),
`type_${integType}`,
integId,
);
2020-09-30 16:12:00 -06:00
},
);
2020-09-29 15:09:52 -06:00
}
if (WidgetType.JITSI.matches(this.mockWidget.type)) {
this.messaging.on(`action:${ElementWidgetActions.HangupCall}`,
(ev: CustomEvent<IHangupCallApiRequest>) => {
if (ev.detail.data?.errorMessage) {
2022-06-14 17:51:51 +01:00
Modal.createDialog(ErrorDialog, {
title: _t("Connection lost"),
description: _t("You were disconnected from the call. (Error: %(message)s)", {
message: ev.detail.data.errorMessage,
}),
});
}
},
);
}
}
public async prepare(): Promise<void> {
// Ensure the variables are ready for us to be rendered before continuing
await (WidgetVariableCustomisations?.isReady?.() ?? Promise.resolve());
if (this.scalarToken) return;
const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget, this.roomId);
2020-09-29 15:09:52 -06:00
if (existingMessaging) this.messaging = existingMessaging;
try {
if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) {
const managers = IntegrationManagers.sharedInstance();
if (managers.hasManager()) {
// TODO: Pick the right manager for the widget
const defaultManager = managers.getPrimaryManager();
if (WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
const scalar = defaultManager.getScalarClient();
this.scalarToken = await scalar.getScalarToken();
}
}
}
} catch (e) {
// All errors are non-fatal
2021-10-15 16:30:53 +02:00
logger.error("Error preparing widget communications: ", e);
}
}
/**
* Stops the widget messaging for if it is started. Skips stopping if it is an active
* widget.
* @param opts
*/
public stopMessaging(opts = { forceDestroy: false }) {
if (!opts?.forceDestroy && ActiveWidgetStore.instance.getWidgetPersistence(this.mockWidget.id, this.roomId)) {
logger.log("Skipping destroy - persistent widget");
2020-09-29 15:09:52 -06:00
return;
}
if (!this.started) return;
WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId);
this.messaging = null;
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().off(ClientEvent.Event, this.onEvent);
MatrixClientPeg.get().off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
}
}
private onEvent = (ev: MatrixEvent) => {
2021-05-18 16:24:38 +01:00
MatrixClientPeg.get().decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.feedEvent(ev);
};
private onEventDecrypted = (ev: MatrixEvent) => {
if (ev.isDecryptionFailure()) return;
this.feedEvent(ev);
};
private feedEvent(ev: MatrixEvent) {
if (!this.messaging) return;
// Check to see if this event would be before or after our "read up to" marker. If it's
// before, or we can't decide, then we assume the widget will have already seen the event.
// If the event is after, or we don't have a marker for the room, then we'll send it through.
//
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
// receiving out-of-order events from backfill and such.
const upToEventId = this.readUpToMap[ev.getRoomId()];
if (upToEventId) {
// Small optimization for exact match (prevent search)
if (upToEventId === ev.getId()) {
return;
}
let isBeforeMark = true;
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
// to avoid overusing the CPU.
const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline();
const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
for (const timelineEvent of events) {
if (timelineEvent.getId() === upToEventId) {
break;
} else if (timelineEvent.getId() === ev.getId()) {
isBeforeMark = false;
break;
}
}
if (isBeforeMark) {
// Ignore the event: it is before our interest.
return;
}
}
this.readUpToMap[ev.getRoomId()] = ev.getId();
2021-07-14 10:18:55 -06:00
const raw = ev.getEffectiveEvent();
this.messaging.feedEvent(raw, this.eventListenerRoomId).catch(e => {
2021-10-15 16:30:53 +02:00
logger.error("Error sending event to widget: ", e);
});
}
}