diff --git a/web/app/app.module.ts b/web/app/app.module.ts index 1c6dd2b..5ca3988 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -22,6 +22,7 @@ import { RssConfigComponent } from "./configs/rss/rss-config.component"; import { IrcConfigComponent } from "./configs/irc/irc-config.component"; import { IrcApiService } from "./shared/irc-api.service"; import { TravisCiConfigComponent } from "./configs/travisci/travisci-config.component"; +import { CustomWidgetConfigComponent } from "./configs/widget/custom_widget/custom_widget-config.component"; @NgModule({ imports: [ @@ -45,6 +46,7 @@ import { TravisCiConfigComponent } from "./configs/travisci/travisci-config.comp RssConfigComponent, IrcConfigComponent, TravisCiConfigComponent, + CustomWidgetConfigComponent, // Vendor ], @@ -61,6 +63,7 @@ import { TravisCiConfigComponent } from "./configs/travisci/travisci-config.comp RssConfigComponent, TravisCiConfigComponent, IrcConfigComponent, + CustomWidgetConfigComponent, ] }) export class AppModule { diff --git a/web/app/configs/widget/custom_widget/custom_widget-config.component.html b/web/app/configs/widget/custom_widget/custom_widget-config.component.html new file mode 100644 index 0000000..d7a5e93 --- /dev/null +++ b/web/app/configs/widget/custom_widget/custom_widget-config.component.html @@ -0,0 +1,65 @@ +
+ +
+ +

Configure custom widgets

+
+
+
+
+

Loading widgets...

+
+
+
+
+
+
+
+
+ + + + +
+
+
+ {{ widget.name || widget.url }} (added by {{ widget.ownerId }}) + + +
+ + + + +
+
+
+
+
+
\ No newline at end of file diff --git a/web/app/configs/widget/custom_widget/custom_widget-config.component.scss b/web/app/configs/widget/custom_widget/custom_widget-config.component.scss new file mode 100644 index 0000000..12e299a --- /dev/null +++ b/web/app/configs/widget/custom_widget/custom_widget-config.component.scss @@ -0,0 +1 @@ +// component styles are encapsulated and only applied to their components diff --git a/web/app/configs/widget/custom_widget/custom_widget-config.component.ts b/web/app/configs/widget/custom_widget/custom_widget-config.component.ts new file mode 100644 index 0000000..2c9c81d --- /dev/null +++ b/web/app/configs/widget/custom_widget/custom_widget-config.component.ts @@ -0,0 +1,113 @@ +import { Component } from "@angular/core"; +import { ModalComponent, DialogRef } from "ngx-modialog"; +import { WidgetComponent } from "../widget.component"; +import { ScalarService } from "../../../shared/scalar.service"; +import { ConfigModalContext } from "../../../integration/integration.component"; +import { ToasterService } from "angular2-toaster"; +import { Widget, WIDGET_DIM_CUSTOM, WIDGET_SCALAR_CUSTOM } from "../../../shared/models/widget"; + +// TODO: A lot of this can probably be abstracted out for other widgets (even the UI), possibly even for other integrations + +@Component({ + selector: "my-customwidget-config", + templateUrl: "custom_widget-config.component.html", + styleUrls: ["custom_widget-config.component.scss", "./../../config.component.scss"], +}) +export class CustomWidgetConfigComponent extends WidgetComponent implements ModalComponent { + + public isLoading = true; + public isUpdating = false; + public widgets: Widget[]; + public widgetUrl = ""; + + private toggledWidgets: string[] = []; + + constructor(public dialog: DialogRef, + private toaster: ToasterService, + scalarService: ScalarService) { + super(scalarService, dialog.context.roomId); + + this.getWidgetsOfType(WIDGET_DIM_CUSTOM, WIDGET_SCALAR_CUSTOM).then(widgets => { + this.widgets = widgets; + this.isLoading = false; + this.isUpdating = false; + }); + } + + public addWidget() { + let constructedWidget: Widget = { + id: "dimension-" + (new Date().getTime()), + url: this.widgetUrl, + type: WIDGET_DIM_CUSTOM, + name: "Custom Widget", + }; + + this.isUpdating = true; + this.scalarApi.setWidget(this.roomId, constructedWidget) + .then(() => this.widgets.push(constructedWidget)) + .then(() => { + this.isUpdating = false; + this.widgetUrl = ""; + this.toaster.pop("success", "Widget added!"); + }) + .catch(err => { + this.toaster.pop("error", err.json().error); + console.error(err); + this.isUpdating = false; + }); + } + + public saveWidget(widget: Widget) { + if (widget.newUrl.trim().length === 0) { + this.toaster.pop("warning", "Please enter a URL for the widget"); + return; + } + + widget.name = widget.newName || "Custom Widget"; + widget.url = widget.newUrl; + + this.isUpdating = true; + this.scalarApi.setWidget(this.roomId, widget) + .then(() => this.toggleWidget(widget)) + .then(() => { + this.isUpdating = false; + this.toaster.pop("success", "Widget updated!"); + }) + .catch(err => { + this.toaster.pop("error", err.json().error); + console.error(err); + this.isUpdating = false; + }); + } + + public removeWidget(widget: Widget) { + this.isUpdating = true; + this.scalarApi.deleteWidget(this.roomId, widget) + .then(() => this.widgets.splice(this.widgets.indexOf(widget), 1)) + .then(() => { + this.isUpdating = false; + this.toaster.pop("success", "Widget deleted!"); + }) + .catch(err => { + this.toaster.pop("error", err.json().error); + console.error(err); + this.isUpdating = false; + }); + } + + public editWidget(widget: Widget) { + widget.newName = widget.name || "Custom Widget"; + widget.newUrl = widget.url; + this.toggleWidget(widget); + } + + public toggleWidget(widget: Widget) { + let idx = this.toggledWidgets.indexOf(widget.id); + if (idx === -1) this.toggledWidgets.push(widget.id); + else this.toggledWidgets.splice(idx, 1); + } + + public isWidgetToggled(widget: Widget) { + return this.toggledWidgets.indexOf(widget.id) !== -1; + } +} diff --git a/web/app/configs/widget/widget.component.ts b/web/app/configs/widget/widget.component.ts new file mode 100644 index 0000000..0cc1098 --- /dev/null +++ b/web/app/configs/widget/widget.component.ts @@ -0,0 +1,23 @@ +import { ScalarService } from "../../shared/scalar.service"; +import { Widget, ScalarToWidgets } from "../../shared/models/widget"; + +export class WidgetComponent { + + constructor(protected scalarApi: ScalarService, protected roomId: string) { + } + + protected getWidgetsOfType(type: string, altType: string = null): Promise { + return this.scalarApi.getWidgets(this.roomId) + .then(resp => ScalarToWidgets(resp)) + .then(widgets => { + let filtered: Widget[] = []; + + for (let widget of widgets) { + if (widget.type === type || (altType && widget.type === altType)) + filtered.push(widget); + } + + return filtered; + }); + } +} diff --git a/web/app/integration/integration.component.html b/web/app/integration/integration.component.html index 3f9d756..44ce08d 100644 --- a/web/app/integration/integration.component.html +++ b/web/app/integration/integration.component.html @@ -3,7 +3,7 @@
{{ integration.name }}
-
+
diff --git a/web/app/riot/riot.component.ts b/web/app/riot/riot.component.ts index eca1ca1..e7f6efe 100644 --- a/web/app/riot/riot.component.ts +++ b/web/app/riot/riot.component.ts @@ -54,6 +54,12 @@ export class RiotComponent { private updateIntegrationState(integration: Integration) { integration.hasConfig = IntegrationService.hasConfig(integration); + if (integration.type === "widget") { + integration.isEnabled = true; + integration.isBroken = false; + return Promise.resolve(); + } + if (integration.requirements) { let keys = _.keys(integration.requirements); let promises = []; diff --git a/web/app/shared/integration.service.ts b/web/app/shared/integration.service.ts index 1a8d462..2705602 100644 --- a/web/app/shared/integration.service.ts +++ b/web/app/shared/integration.service.ts @@ -4,6 +4,7 @@ import { RssConfigComponent } from "../configs/rss/rss-config.component"; import { ContainerContent } from "ngx-modialog"; import { IrcConfigComponent } from "../configs/irc/irc-config.component"; import { TravisCiConfigComponent } from "../configs/travisci/travisci-config.component"; +import { CustomWidgetConfigComponent } from "../configs/widget/custom_widget/custom_widget-config.component"; @Injectable() export class IntegrationService { @@ -16,7 +17,10 @@ export class IntegrationService { }, "bridge": { "irc": true, - } + }, + "widget": { + "customwidget": true + }, }; private static components = { @@ -26,7 +30,10 @@ export class IntegrationService { }, "bridge": { "irc": IrcConfigComponent, - } + }, + "widget": { + "customwidget": CustomWidgetConfigComponent, + }, }; static isSupported(integration: Integration): boolean { diff --git a/web/app/shared/models/scalar_responses.ts b/web/app/shared/models/scalar_responses.ts index 4b3e4be..cc6024a 100644 --- a/web/app/shared/models/scalar_responses.ts +++ b/web/app/shared/models/scalar_responses.ts @@ -30,4 +30,19 @@ export interface JoinRuleStateResponse extends ScalarRoomResponse { response: { join_rule: string; }; +} + +export interface WidgetsResponse extends ScalarRoomResponse { + response: { + type: "im.vector.modular.widgets"; + state_key: string; + sender: string; + room_id: string; + content: { + type: string; + url: string; + name?: string; + data?: any; + } + }[]; } \ No newline at end of file diff --git a/web/app/shared/models/widget.ts b/web/app/shared/models/widget.ts new file mode 100644 index 0000000..9446ef0 --- /dev/null +++ b/web/app/shared/models/widget.ts @@ -0,0 +1,42 @@ +import { WidgetsResponse } from "./scalar_responses"; + +// Scalar's widget types (known) +export const WIDGET_SCALAR_CUSTOM = "customwidget"; +export const WIDGET_SCALAR_ETHERPAD = "etherpad"; +export const WIDGET_SCALAR_GOOGLEDOCS = "googledocs"; +export const WIDGET_SCALAR_JITSI = "jitsi"; +export const WIDGET_SCALAR_YOUTUBE = "youtube"; +export const WIDGET_SCALAR_GRAFANA = "grafana"; + +// Dimension has its own set of types to ensure that we don't conflict with Scalar +export const WIDGET_DIM_CUSTOM = "dimension-customwidget"; + +export interface Widget { + id: string; + type: string; + url: string; + name?: string; + data?: any; + ownerId?: string; + + // used only in ui + newName?: string; + newUrl?: string; +} + +export function ScalarToWidgets(scalarResponse: WidgetsResponse): Widget[] { + let widgets = []; + + for (let event of scalarResponse.response) { + widgets.push({ + id: event.state_key, + type: event.content.type, + url: event.content.url, + name: event.content.name, + data: event.content.data, + ownerId: event.sender, + }); + } + + return widgets; +} diff --git a/web/app/shared/scalar.service.ts b/web/app/shared/scalar.service.ts index 7d57ba1..6fc2f71 100644 --- a/web/app/shared/scalar.service.ts +++ b/web/app/shared/scalar.service.ts @@ -1,6 +1,12 @@ import { Injectable } from "@angular/core"; import * as randomString from "random-string"; -import { MembershipStateResponse, ScalarSuccessResponse, JoinRuleStateResponse } from "./models/scalar_responses"; +import { + MembershipStateResponse, + ScalarSuccessResponse, + JoinRuleStateResponse, + WidgetsResponse +} from "./models/scalar_responses"; +import { Widget } from "./models/widget"; @Injectable() export class ScalarService { @@ -36,6 +42,32 @@ export class ScalarService { }); } + public getWidgets(roomId: string): Promise { + return this.callAction("get_widgets", { + room_id: roomId + }); + } + + public setWidget(roomId: string, widget: Widget): Promise { + return this.callAction("set_widget", { + room_id: roomId, + widget_id: widget.id, + type: widget.type, + url: widget.url, + name: widget.name, + data: widget.data + }); + } + + public deleteWidget(roomId: string, widget: Widget): Promise { + return this.callAction("set_widget", { + room_id: roomId, + widget_id: widget.id, + type: widget.type, // required for some reason + url: "" + }); + } + public close(): void { this.callAction("close_scalar", {}); } diff --git a/web/public/img/avatars/customwidget.png b/web/public/img/avatars/customwidget.png new file mode 100644 index 0000000..8ec4211 Binary files /dev/null and b/web/public/img/avatars/customwidget.png differ