diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 3b3926b724..c89a0ceeeb 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -96,6 +96,17 @@ export default class WidgetMessaging { }); } + /** + * Tells the widget that it should terminate now. + * @returns {Promise<*>} Resolves when widget has acknowledged the message. + */ + terminate() { + return this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: KnownWidgetActions.Terminate, + }); + } + /** * Request a screenshot from a widget * @return {Promise} To be resolved with screenshot data when it has been generated diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 527436b0e4..9129b8fe48 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -39,6 +39,8 @@ import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import PersistedElement from "./PersistedElement"; import {WidgetType} from "../../../widgets/WidgetType"; +import {Capability} from "../../../widgets/WidgetApi"; +import {sleep} from "../../../utils/promise"; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ENABLE_REACT_PERF = false; @@ -341,23 +343,37 @@ export default class AppTile extends React.Component { /** * Ends all widget interaction, such as cancelling calls and disabling webcams. * @private + * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ _endWidgetActions() { - // HACK: This is a really dirty way to ensure that Jitsi cleans up - // its hold on the webcam. Without this, the widget holds a media - // stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351 - if (this._appFrame.current) { - // In practice we could just do `+= ''` to trick the browser - // into thinking the URL changed, however I can foresee this - // being optimized out by a browser. Instead, we'll just point - // the iframe at a page that is reasonably safe to use in the - // event the iframe doesn't wink away. - // This is relative to where the Riot instance is located. - this._appFrame.current.src = 'about:blank'; + let terminationPromise; + + if (this._hasCapability(Capability.ReceiveTerminate)) { + // Wait for widget to terminate within a timeout + const timeout = 2000; + const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id); + terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]); + } else { + terminationPromise = Promise.resolve(); } - // Delete the widget from the persisted store for good measure. - PersistedElement.destroyElement(this._persistKey); + return terminationPromise.finally(() => { + // HACK: This is a really dirty way to ensure that Jitsi cleans up + // its hold on the webcam. Without this, the widget holds a media + // stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351 + if (this._appFrame.current) { + // In practice we could just do `+= ''` to trick the browser + // into thinking the URL changed, however I can foresee this + // being optimized out by a browser. Instead, we'll just point + // the iframe at a page that is reasonably safe to use in the + // event the iframe doesn't wink away. + // This is relative to where the Riot instance is located. + this._appFrame.current.src = 'about:blank'; + } + + // Delete the widget from the persisted store for good measure. + PersistedElement.destroyElement(this._persistKey); + }); } /* If user has permission to modify widgets, delete the widget, @@ -381,12 +397,12 @@ export default class AppTile extends React.Component { } this.setState({deleting: true}); - this._endWidgetActions(); - - WidgetUtils.setRoomWidget( - this.props.room.roomId, - this.props.app.id, - ).catch((e) => { + this._endWidgetActions().then(() => { + return WidgetUtils.setRoomWidget( + this.props.room.roomId, + this.props.app.id, + ); + }).catch((e) => { console.error('Failed to delete widget', e); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -669,6 +685,17 @@ export default class AppTile extends React.Component { } _onPopoutWidgetClick() { + // Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them + // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop). + if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) { + this._endWidgetActions().then(() => { + if (this._appFrame.current) { + // Reload iframe + this._appFrame.current.src = this._getRenderedUrl(); + this.setState({}); + } + }); + } // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index 35e23f0429..b48ec481ba 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -421,6 +421,7 @@ export default class WidgetUtils { if (WidgetType.JITSI.matches(appType)) { capWhitelist.push(Capability.AlwaysOnScreen); } + capWhitelist.push(Capability.ReceiveTerminate); return capWhitelist; } diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts index 05237d258f..795c6648ef 100644 --- a/src/widgets/WidgetApi.ts +++ b/src/widgets/WidgetApi.ts @@ -18,11 +18,13 @@ limitations under the License. // https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts import { randomString } from "matrix-js-sdk/src/randomstring"; +import { EventEmitter } from "events"; export enum Capability { Screenshot = "m.capability.screenshot", Sticker = "m.sticker", AlwaysOnScreen = "m.always_on_screen", + ReceiveTerminate = "im.vector.receive_terminate", } export enum KnownWidgetActions { @@ -34,6 +36,7 @@ export enum KnownWidgetActions { ReceiveOpenIDCredentials = "openid_credentials", SetAlwaysOnScreen = "set_always_on_screen", ClientReady = "im.vector.ready", + Terminate = "im.vector.terminate", } export type WidgetAction = KnownWidgetActions | string; @@ -62,8 +65,13 @@ export interface FromWidgetRequest extends WidgetRequest { /** * Handles Riot <--> Widget interactions for embedded/standalone widgets. + * + * Emitted events: + * - terminate(wait): client requested the widget to terminate. + * Call the argument 'wait(promise)' to postpone the finalization until + * the given promise resolves. */ -export class WidgetApi { +export class WidgetApi extends EventEmitter { private origin: string; private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {}; private readyPromise: Promise; @@ -75,6 +83,8 @@ export class WidgetApi { public expectingExplicitReady = false; constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) { + super(); + this.origin = new URL(currentUrl).origin; this.readyPromise = new Promise(resolve => this.readyPromiseResolve = resolve); @@ -98,6 +108,17 @@ export class WidgetApi { // Automatically acknowledge so we can move on this.replyToRequest(payload, {}); + } else if (payload.action === KnownWidgetActions.Terminate) { + // Finalization needs to be async, so postpone with a promise + let finalizePromise = Promise.resolve(); + const wait = (promise) => { + finalizePromise = finalizePromise.then(value => promise); + }; + this.emit('terminate', wait); + Promise.resolve(finalizePromise).then(() => { + // Acknowledge that we're shut down now + this.replyToRequest(payload, {}); + }); } else { console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`); }