From e3f27156e05de60e4ea19fc64ab93f83a1699c74 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Thu, 23 Jul 2020 23:10:27 +0200 Subject: [PATCH] Add the client-side widget code Here is where the actual code that runs in the widget's iframe is. This includes the HTML/CSS stuff, the definitions for API request/responses, some routing and the javascript which makes requests to the new /join api endpoint. --- web/app/app.module.ts | 2 + web/app/app.routing.ts | 2 + web/app/shared/models/integration.ts | 13 +- .../integrations/bigbluebutton-api.service.ts | 16 ++ .../bigbluebutton.component.html | 25 +++ .../bigbluebutton.component.scss | 32 ++++ .../bigbluebutton/bigbluebutton.component.ts | 152 ++++++++++++++++++ 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 web/app/shared/services/integrations/bigbluebutton-api.service.ts create mode 100644 web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.html create mode 100644 web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.scss create mode 100644 web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts diff --git a/web/app/app.module.ts b/web/app/app.module.ts index c7a8def..9d9dbf7 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -119,6 +119,7 @@ import { AdminNewEditTermsComponent } from "./admin/terms/new-edit/new-edit.comp import { AdminTermsNewEditPublishDialogComponent } from "./admin/terms/new-edit/publish/publish.component"; import { TermsWidgetWrapperComponent } from "./widget-wrappers/terms/terms.component"; import { BigBlueButtonConfigComponent } from "./configs/widget/bigbluebutton/bigbluebutton.widget.component"; +import { BigBlueButtonWidgetWrapperComponent } from "./widget-wrappers/bigbluebutton/bigbluebutton.component"; import { BigBlueButtonApiService } from "./shared/services/integrations/bigbluebutton-api.service"; @NgModule({ @@ -149,6 +150,7 @@ import { BigBlueButtonApiService } from "./shared/services/integrations/bigblueb FullscreenButtonComponent, VideoWidgetWrapperComponent, JitsiWidgetWrapperComponent, + BigBlueButtonWidgetWrapperComponent, GCalWidgetWrapperComponent, BigBlueButtonConfigComponent, RiotHomeComponent, diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index 1e36590..8bbd11c 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -2,6 +2,7 @@ import { RouterModule, Routes } from "@angular/router"; import { HomeComponent } from "./home/home.component"; import { RiotComponent } from "./riot/riot.component"; import { GenericWidgetWrapperComponent } from "./widget-wrappers/generic/generic.component"; +import { BigBlueButtonWidgetWrapperComponent } from "./widget-wrappers/bigbluebutton/bigbluebutton.component"; import { BigBlueButtonConfigComponent } from "./configs/widget/bigbluebutton/bigbluebutton.widget.component"; import { VideoWidgetWrapperComponent } from "./widget-wrappers/video/video.component"; import { JitsiWidgetWrapperComponent } from "./widget-wrappers/jitsi/jitsi.component"; @@ -292,6 +293,7 @@ const routes: Routes = [ {path: "generic", component: GenericWidgetWrapperComponent}, {path: "video", component: VideoWidgetWrapperComponent}, {path: "jitsi", component: JitsiWidgetWrapperComponent}, + {path: "bigbluebutton", component: BigBlueButtonWidgetWrapperComponent}, {path: "gcal", component: GCalWidgetWrapperComponent}, {path: "stickerpicker", component: StickerPickerWidgetWrapperComponent}, {path: "generic-fullscreen", component: GenericFullscreenWidgetWrapperComponent}, diff --git a/web/app/shared/models/integration.ts b/web/app/shared/models/integration.ts index a841e4d..6d8a444 100644 --- a/web/app/shared/models/integration.ts +++ b/web/app/shared/models/integration.ts @@ -64,6 +64,11 @@ export interface FE_Sticker { }; } +export interface FE_BigBlueButtonJoin { + // The meeting URL the client should load to join the meeting + url: string; +} + export interface FE_StickerConfig { enabled: boolean; stickerBot: string; @@ -88,8 +93,14 @@ export interface FE_JitsiWidget extends FE_Widget { }; } +export interface FE_BigBlueButtonWidget extends FE_Widget { + options: { + conferenceUrl: string; + }; +} + export interface FE_IntegrationRequirement { condition: "publicRoom" | "canSendEventTypes" | "userInRoom"; argument: any; expectedValue: any; -} \ No newline at end of file +} diff --git a/web/app/shared/services/integrations/bigbluebutton-api.service.ts b/web/app/shared/services/integrations/bigbluebutton-api.service.ts new file mode 100644 index 0000000..4a23cfe --- /dev/null +++ b/web/app/shared/services/integrations/bigbluebutton-api.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from "@angular/core"; +import { AuthedApi } from "../authed-api"; +import { FE_BigBlueButtonJoin } from "../../models/integration" +import { HttpClient } from "@angular/common/http"; +import { ApiError } from "../../../../../src/api/ApiError"; + +@Injectable() +export class BigBlueButtonApiService extends AuthedApi { + constructor(http: HttpClient) { + super(http); + } + + public joinMeeting(url: string, name: string): Promise { + return this.authedGet("/api/v1/dimension/bigbluebutton/join", {greenlightUrl: url, fullName: name}).toPromise(); + } +} \ No newline at end of file diff --git a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.html b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.html new file mode 100644 index 0000000..cf07776 --- /dev/null +++ b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.html @@ -0,0 +1,25 @@ + + +
+
+
+

+
+ +
+

BigBlueButton Conference

+ +
+
+
+
diff --git a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.scss b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.scss new file mode 100644 index 0000000..1918e2a --- /dev/null +++ b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.scss @@ -0,0 +1,32 @@ +// component styles are encapsulated and only applied to their components +@import "../../../style/themes/themes"; + +@include themifyComponent() { + #bigBlueButtonContainer { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + + .join-conference-wrapper { + display: table; + position: absolute; + height: 100%; + width: 100%; + background-color: themed(widgetWelcomeBgColor); + } + + .join-conference-boat { + display: table-cell; + vertical-align: middle; + } + + .join-conference-prompt { + margin-left: auto; + margin-right: auto; + width: 90%; + text-align: center; + } +} diff --git a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts new file mode 100644 index 0000000..a423e99 --- /dev/null +++ b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts @@ -0,0 +1,152 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { WidgetApiService } from "../../shared/services/integrations/widget-api.service"; +import { Subscription } from "rxjs/Subscription"; +import { ScalarWidgetApi } from "../../shared/services/scalar/scalar-widget.api"; +import { CapableWidget } from "../capable-widget"; +import { DomSanitizer, SafeUrl } from "@angular/platform-browser"; +import { BigBlueButtonApiService } from "../../shared/services/integrations/bigbluebutton-api.service"; +import { FE_BigBlueButtonJoin } from "../../shared/models/integration"; + +@Component({ + selector: "my-bigbluebutton-widget-wrapper", + templateUrl: "bigbluebutton.component.html", + styleUrls: ["bigbluebutton.component.scss"], +}) +export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implements OnInit, OnDestroy { + + public canEmbed = true; + + /** + * User metadata passed to us by the client + */ + private conferenceUrl: string; + private displayName: string; + private userId: string; + + /** + * The poll period in ms while waiting for a meeting to start + */ + private pollIntervalMillis = 5000; + + /** + * Subscriber for messages from the client via the postMessage API + */ + private bigBlueButtonApiSubscription: Subscription; + + /** + * A status message to display to the user in the widget, typically for loading messages + */ + public statusMessage: string; + + /** + * Whether we are currently in a meeting + */ + private inMeeting: boolean = false; + + /** + * The URL to embed into the iframe + */ + public embedUrl: SafeUrl = null; + + constructor(activatedRoute: ActivatedRoute, + private bigBlueButtonApi: BigBlueButtonApiService, + private widgetApi: WidgetApiService, + private sanitizer: DomSanitizer) { + super(); + this.supportsAlwaysOnScreen = true; + + let params: any = activatedRoute.snapshot.queryParams; + + console.log("BigBlueButton: Given greenlight url: " + params.conferenceUrl); + + this.conferenceUrl = params.conferenceUrl; + this.displayName = params.displayName; + this.userId = params.userId || params.email; // Element uses `email` when placing a conference call + + // Set the widget ID if we have it + ScalarWidgetApi.widgetId = params.widgetId; + } + + public ngOnInit() { + super.ngOnInit(); + } + + public onIframeLoad() { + if (this.inMeeting) { + // The meeting has ended and we've come back full circle + this.inMeeting = false; + this.statusMessage = null; + this.embedUrl = null; + + ScalarWidgetApi.sendSetAlwaysOnScreen(false); + return; + } + + // Have a toggle for whether we're in a meeting. We do this as we don't have a method + // of checking which URL was just loaded in the iframe (due to different origin domains + // and browser security), so we have to guess that it'll always be the second load (the + // first being joining the meeting) + this.inMeeting = true; + + // We've successfully joined the meeting + ScalarWidgetApi.sendSetAlwaysOnScreen(true); + } + + public joinConference(updateStatusMessage: boolean = true) { + if (updateStatusMessage) { + // Inform the user that we're loading their meeting + this.statusMessage = "Joining conference..."; + } + + // Generate a nick to display in the meeting + const joinName = `${this.displayName} (${this.userId})`; + + // Make a request to Dimension requesting the join URL + console.log("BigBlueButton: joining via greenlight url:", this.conferenceUrl); + this.bigBlueButtonApi.joinMeeting(this.conferenceUrl, joinName).then((response) => { + if ("errorCode" in response) { + // This is an instance of ApiError + if (response.errorCode == "WAITING_FOR_MEETING_START") { + // The meeting hasn't started yet + this.statusMessage = "Waiting for conference to start..."; + + // Poll until it has + setTimeout(this.joinConference.bind(this), this.pollIntervalMillis, false); + return; + } + + // Otherwise this is a generic error + this.statusMessage = "An error occurred while loading the meeting"; + } + + const joinUrl = (response as FE_BigBlueButtonJoin).url; + + // Check if the given URL is embeddable + this.widgetApi.isEmbeddable(joinUrl).then(result => { + this.canEmbed = result.canEmbed; + this.statusMessage = null; + + // Embed the return meeting URL, joining the meeting + this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(joinUrl); + + // Inform the client that we would like the meeting to remain visible for its duration + ScalarWidgetApi.sendSetAlwaysOnScreen(true); + }).catch(err => { + console.error(err); + this.canEmbed = false; + this.statusMessage = "Unable to embed meeting"; + }); + }); + } + + public ngOnDestroy() { + if (this.bigBlueButtonApiSubscription) this.bigBlueButtonApiSubscription.unsubscribe(); + } + + protected onCapabilitiesSent(): void { + super.onCapabilitiesSent(); + ScalarWidgetApi.sendSetAlwaysOnScreen(false); + } + +}