diff --git a/package.json b/package.json index 96458d7b..6775eeaf 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,8 @@ "modernizr": "^3.6.0", "olm": "https://packages.matrix.org/npm/olm/olm-3.1.0.tgz", "prop-types": "^15.6.2", + "querystring": "^0.2.0", + "random-string": "^0.2.0", "react": "^15.6.0", "react-dom": "^15.6.0", "sanitize-html": "^1.19.1", diff --git a/src/vector/inline_widget_wrapper/WidgetApi.js b/src/vector/inline_widget_wrapper/WidgetApi.js new file mode 100644 index 00000000..ecdd2b85 --- /dev/null +++ b/src/vector/inline_widget_wrapper/WidgetApi.js @@ -0,0 +1,105 @@ +/* +Copyright 2019 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 randomString from "random-string"; + +// Dev note: This is largely inspired by Dimension. Used with permission. +// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts#L1 +export default class WidgetApi { + + _origin; + _widgetId; + _capabilities; + _inFlightRequests = {}; // { reqId => replyFn(payload) } + + constructor(origin, widgetId, capabilities) { + this._origin = new URL(origin).origin; + this._widgetId = widgetId; + this._capabilities = capabilities; + + const toWidgetActions = { + "capabilities": this._onCapabilitiesRequest.bind(this), + }; + + window.addEventListener("message", event => { + if (event.origin !== this._origin) return; // ignore due to invalid origin + if (!event.data) return; + if (event.data.widgetId !== this._widgetId) return; + + const payload = event.data; + if (payload.api === "toWidget" && payload.action) { + console.log("[Inline Widget] Got toWidget: " + JSON.stringify(payload)); + const handler = toWidgetActions[payload.action]; + if (handler) handler(payload); + } + if (payload.api === "fromWidget" && this._inFlightRequests[payload.requestId]) { + console.log("[Inline Widget] Got fromWidget reply: " + JSON.stringify(payload)); + const handler = this._inFlightRequests[payload.requestId]; + delete this._inFlightRequests[payload.requestId]; + handler(payload); + } + }); + } + + sendText(text) { + this.sendEvent("m.room.message", {msgtype: "m.text", body: text}); + } + + sendNotice(text) { + this.sendEvent("m.room.message", {msgtype: "m.notice", body: text}); + } + + sendEvent(eventType, content) { + this._callAction("send_event", { + type: eventType, + content: content, + }); + } + + _callAction(action, payload) { + if (!window.parent) { + return; + } + + const request = { + api: "fromWidget", + widgetId: this._widgetId, + action: action, + requestId: randomString({length: 16}), + data: payload, + }; + + this._inFlightRequests[request.requestId] = () => {}; + + console.log("[Inline Widget] Sending fromWidget: ", request); + window.parent.postMessage(request, this._origin); + } + + _replyPayload(incPayload, payload) { + if (!window.parent) { + return; + } + + let request = JSON.parse(JSON.stringify(incPayload)); + request["response"] = payload; + + window.parent.postMessage(request, this._origin); + } + + _onCapabilitiesRequest(payload) { + this._replyPayload(payload, {capabilities: this._capabilities}); + } +} \ No newline at end of file diff --git a/src/vector/inline_widget_wrapper/index.html b/src/vector/inline_widget_wrapper/index.html new file mode 100644 index 00000000..ec74cdb4 --- /dev/null +++ b/src/vector/inline_widget_wrapper/index.html @@ -0,0 +1,5 @@ + +
+ + + diff --git a/src/vector/inline_widget_wrapper/index.js b/src/vector/inline_widget_wrapper/index.js new file mode 100644 index 00000000..ede93bf2 --- /dev/null +++ b/src/vector/inline_widget_wrapper/index.js @@ -0,0 +1,71 @@ +/* +Copyright 2019 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 queryString from "querystring"; +import WidgetApi from "./WidgetApi"; + +let widgetApi; +try { + const qs = queryString.parse(window.location.search.substring(1)); + if (!qs["widgetId"]) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Missing widgetId in query string"); + } + if (!qs["parentUrl"]) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Missing parentUrl in query string"); + } + + const widgetOpts = JSON.parse(atob(window.location.hash + .substring(1) + .replace(/-/g, '+') + .replace(/_/g, '/'))); + + // This widget wrapper is always on the same origin as the client itself + widgetApi = new WidgetApi(qs["parentUrl"], qs["widgetId"], widgetOpts["capabilities"]); + + document.getElementById("widgetHtml").innerHTML = widgetOpts['html']; + bindButtons(); +} catch (e) { + console.error("[Inline Widget Wrapper] Error loading widget from URL: ", e); + document.getElementById("widgetHtml").innerText = "Failed to load widget"; +} + +function bindButtons() { + const buttons = document.getElementsByTagName("button"); + if (!buttons) return; + for (const button of buttons) { + button.addEventListener("click", onClick); + } +} + +function onClick(event) { + if (!event.target) return; + + const action = event.target.getAttribute("data-mx-action"); + if (!action) return; // TODO: Submit form or something? + + const value = event.target.getAttribute("data-mx-value"); + if (!value) return; // ignore - no value + + if (action === "m.send_text") { + widgetApi.sendText(value); + } else if (action === "m.send_notice") { + widgetApi.sendNotice(value); + } else if (action === "m.send_hidden") { + widgetApi.sendEvent("m.room.hidden", {body: value}); + } // else ignore +} +// TODO: Binding of forms, etc \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 3bb08cb3..fc5777f7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,6 +14,7 @@ module.exports = { "indexeddb-worker": "./src/vector/indexeddb-worker.js", "mobileguide": "./src/vector/mobile_guide/index.js", + "inline-widget-wrapper": "./src/vector/inline_widget_wrapper/index.js", // CSS themes "theme-light": "./node_modules/matrix-react-sdk/res/themes/light/css/light.scss", @@ -178,7 +179,7 @@ module.exports = { // bottom of or the bottom of , and I'm a bit scared // about moving them. inject: false, - excludeChunks: ['mobileguide'], + excludeChunks: ['mobileguide', 'inline-widget-wrapper'], vars: { og_image_url: og_image_url, }, @@ -188,6 +189,11 @@ module.exports = { filename: 'mobile_guide/index.html', chunks: ['mobileguide'], }), + new HtmlWebpackPlugin({ + template: './src/vector/inline_widget_wrapper/index.html', + filename: 'inline_widget_wrapper/index.html', + chunks: ['inline-widget-wrapper'], + }), ], devtool: 'source-map', diff --git a/yarn.lock b/yarn.lock index a29e6480..3a61f6b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7176,6 +7176,11 @@ raf@^3.1.0: dependencies: performance-now "^2.1.0" +random-string@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/random-string/-/random-string-0.2.0.tgz#a46e4375352beda9a0d7b0d19ed6d321ecd1d82d" + integrity sha1-pG5DdTUr7amg17DRntbTIezR2C0= + randomatic@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"