mirror of
https://github.com/turt2live/matrix-dimension.git
synced 2024-09-03 23:52:05 +00:00
Merge pull request #367 from anoadragon453/anoa/bigbluebutton
Add BigBlueButton widget support
This commit is contained in:
commit
852bfe0667
207
src/api/dimension/DimensionBigBlueButtonService.ts
Normal file
207
src/api/dimension/DimensionBigBlueButtonService.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import { GET, Path, QueryParam } from "typescript-rest";
|
||||||
|
import * as request from "request";
|
||||||
|
import { LogService } from "matrix-js-snippets";
|
||||||
|
import { URL } from "url";
|
||||||
|
import { BigBlueButtonJoinRequest } from "../../models/Widget";
|
||||||
|
import { BigBlueButtonJoinResponse } from "../../models/WidgetResponses";
|
||||||
|
import { AutoWired } from "typescript-ioc/es6";
|
||||||
|
import { ApiError } from "../ApiError";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for the BigBlueButton widget.
|
||||||
|
*/
|
||||||
|
@Path("/api/v1/dimension/bigbluebutton")
|
||||||
|
@AutoWired
|
||||||
|
export class DimensionBigBlueButtonService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A regex used for extracting the authenticity token from the HTML of a
|
||||||
|
* greenlight server response
|
||||||
|
*/
|
||||||
|
private authenticityTokenRegexp = new RegExp(`name="authenticity_token" value="([^"]+)".*`);
|
||||||
|
|
||||||
|
// join handles the request from a client to join a BigBlueButton meeting
|
||||||
|
//
|
||||||
|
// The client is expected to send a link created by greenlight, the nice UI
|
||||||
|
// that's recommended to be installed on top of BBB, which is itself a BBB
|
||||||
|
// client.
|
||||||
|
//
|
||||||
|
// This greenlight link is nice, but greenlight unfortunately doesn't have any
|
||||||
|
// API, and no simple way for us to translate a link from it into a BBB meeting
|
||||||
|
// URL. It's intended to be loaded by browsers. You enter your preferred name,
|
||||||
|
// click submit, you potentially wait for the meeting to start, and then you
|
||||||
|
// finally get the link to join the meeting, and you load that.
|
||||||
|
//
|
||||||
|
// As there's no other way to do it, we just reverse-engineer it and pretend
|
||||||
|
// to be a browser below. We can't do this from the client side as widgets
|
||||||
|
// run in iframes and browsers can't inspect the content of an iframe if
|
||||||
|
// it's running on a separate domain.
|
||||||
|
//
|
||||||
|
// So the client gets a greenlight URL pasted into it. The flow is then:
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// +---------+ +-----------+ +-------------+ +-----+
|
||||||
|
// | Client | | Dimension | | Greenlight | | BBB |
|
||||||
|
// +---------+ +-----------+ +-------------+ +-----+
|
||||||
|
// | | | |
|
||||||
|
// | | | |
|
||||||
|
// | | | |
|
||||||
|
// | | | |
|
||||||
|
// | /bigbluebutton/join&greenlightUrl=https://.../abc-def-123&fullName=bob | | |
|
||||||
|
// |---------------------------------------------------------------------------->| | |
|
||||||
|
// | | | |
|
||||||
|
// | | GET https://.../abc-def-123 | |
|
||||||
|
// | |-------------------------------------------------------------------------------------->| |
|
||||||
|
// | | | |
|
||||||
|
// | | Have some HTML | |
|
||||||
|
// | |<--------------------------------------------------------------------------------------| |
|
||||||
|
// | | | |
|
||||||
|
// | | Extract authenticity_token from HTML | |
|
||||||
|
// | |------------------------------------- | |
|
||||||
|
// | | | | |
|
||||||
|
// | |<------------------------------------ | |
|
||||||
|
// | | | |
|
||||||
|
// | | Extract cookies from HTTP response | |
|
||||||
|
// | |----------------------------------- | |
|
||||||
|
// | | | | |
|
||||||
|
// | |<---------------------------------- | |
|
||||||
|
// | | | |
|
||||||
|
// | | POST https://.../abc-def-123&authenticity_token=...&abc-def-123[join_name]=bob | |
|
||||||
|
// | |-------------------------------------------------------------------------------------->| |
|
||||||
|
// |===============================================================================================If the meeting has not started yet================================================|
|
||||||
|
// | | | |
|
||||||
|
// | | HTML https://.../abc-def-123 Meeting not started | |
|
||||||
|
// | |<--------------------------------------------------------------------------------------| |
|
||||||
|
// | | | |
|
||||||
|
// | 400 MEETING_NOT_STARTED_YET | | |
|
||||||
|
// |<----------------------------------------------------------------------------| | |
|
||||||
|
// | | | |
|
||||||
|
// | | | |
|
||||||
|
// | Wait a bit and restart the process | | |
|
||||||
|
// |------------------------------------- | | |
|
||||||
|
// | | | | |
|
||||||
|
// |<------------------------------------ | | |
|
||||||
|
// | | | |
|
||||||
|
// |=================================================================================================================================================================================|
|
||||||
|
// | | | |
|
||||||
|
// | | 302 Location: https://bbb.example.com/join?... | |
|
||||||
|
// | |<--------------------------------------------------------------------------------------| |
|
||||||
|
// | | | |
|
||||||
|
// | | Extract value of Location header | |
|
||||||
|
// | |--------------------------------- | |
|
||||||
|
// | | | | |
|
||||||
|
// | |<-------------------------------- | |
|
||||||
|
// | | | |
|
||||||
|
// | https://bbb.example.com/join?... | | |
|
||||||
|
// |<----------------------------------------------------------------------------| | |
|
||||||
|
// | | | |
|
||||||
|
// | GET https://bbb.example.com/join?... | | |
|
||||||
|
// |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->|
|
||||||
|
// | | | |
|
||||||
|
// | | Send back meeting page HTML | |
|
||||||
|
// |<--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
//
|
||||||
|
@GET
|
||||||
|
@Path("join")
|
||||||
|
public async join(
|
||||||
|
joinRequest: BigBlueButtonJoinRequest,
|
||||||
|
@QueryParam("greenlightUrl") greenlightURL: string,
|
||||||
|
@QueryParam("fullName") fullName: string,
|
||||||
|
): Promise<BigBlueButtonJoinResponse|ApiError> {
|
||||||
|
// Parse the greenlight url and retrieve the path
|
||||||
|
const greenlightMeetingID = new URL(greenlightURL).pathname;
|
||||||
|
|
||||||
|
LogService.info("BigBlueButton", "URL from client: " + greenlightURL);
|
||||||
|
LogService.info("BigBlueButton", "MeetingID: " + greenlightMeetingID);
|
||||||
|
LogService.info("BigBlueButton", "Name given from client: " + fullName);
|
||||||
|
LogService.info("BigBlueButton", joinRequest);
|
||||||
|
|
||||||
|
// Query the URL the user has given us
|
||||||
|
let response = await this.doRequest("GET", greenlightURL);
|
||||||
|
if (!response || !response.body) {
|
||||||
|
throw new Error("Invalid response from Greenlight server while joining meeting");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to extract the authenticity token
|
||||||
|
const matches = response.body.match(this.authenticityTokenRegexp);
|
||||||
|
if (matches.length < 2) {
|
||||||
|
throw new Error("Unable to find authenticity token for given 'greenlightUrl' parameter");
|
||||||
|
}
|
||||||
|
const authenticityToken = matches[1];
|
||||||
|
|
||||||
|
// Give the authenticity token and desired name to greenlight, getting the
|
||||||
|
// join URL in return. Greenlight will send the URL back as a Location:
|
||||||
|
// header. We want to extract and return the contents of this header, rather
|
||||||
|
// than following it ourselves
|
||||||
|
|
||||||
|
// Add authenticity token and full name to the query parameters
|
||||||
|
let queryParams = {authenticity_token: authenticityToken};
|
||||||
|
queryParams[`${greenlightMeetingID}[join_name]`] = fullName;
|
||||||
|
|
||||||
|
// Request the updated URL
|
||||||
|
response = await this.doRequest("POST", greenlightURL, queryParams, "{}", false);
|
||||||
|
if (!response || !response.body) {
|
||||||
|
throw new Error("Invalid response from Greenlight server while joining meeting");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("location" in response.response.headers)) {
|
||||||
|
// We didn't get a meeting URL back. This could either happen due to an issue with the parameters
|
||||||
|
// sent to the server... or the meeting simply hasn't started yet.
|
||||||
|
|
||||||
|
// Assume it hasn't started yet. Send a custom error code back to the client informing them to try
|
||||||
|
// again in a bit
|
||||||
|
return new ApiError(
|
||||||
|
400,
|
||||||
|
{error: "Unable to find meeting URL in greenlight response"},
|
||||||
|
"WAITING_FOR_MEETING_START",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the join URL for the client to load
|
||||||
|
const joinUrl = response.response.headers["location"];
|
||||||
|
LogService.info("BigBlueButton", "Sending back join URL: " + joinUrl)
|
||||||
|
return {url: joinUrl};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRequest(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
qs?: any,
|
||||||
|
body?: any,
|
||||||
|
followRedirect: boolean = true,
|
||||||
|
): Promise<any> {
|
||||||
|
// Query a URL, expecting an HTML response in return
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request({
|
||||||
|
method: method,
|
||||||
|
url: url,
|
||||||
|
qs: qs,
|
||||||
|
body: body,
|
||||||
|
followRedirect: followRedirect,
|
||||||
|
jar: true, // remember cookies between requests
|
||||||
|
json: false, // expect html
|
||||||
|
}, (err, res, _body) => {
|
||||||
|
try {
|
||||||
|
if (err) {
|
||||||
|
LogService.error("BigBlueButtonWidget", "Error calling " + url);
|
||||||
|
LogService.error("BigBlueButtonWidget", err);
|
||||||
|
reject(err);
|
||||||
|
} else if (!res) {
|
||||||
|
LogService.error("BigBlueButtonWidget", "There is no response for " + url);
|
||||||
|
reject(new Error("No response provided - is the service online?"));
|
||||||
|
} else if (res.statusCode !== 200 && res.statusCode !== 302) {
|
||||||
|
LogService.error("BigBlueButtonWidget", "Got status code " + res.statusCode + " when calling " + url);
|
||||||
|
LogService.error("BigBlueButtonWidget", res.body);
|
||||||
|
reject({body: res.body, status: res.statusCode});
|
||||||
|
} else {
|
||||||
|
resolve({body: res.body, response: res});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
LogService.error("BigBlueButtonWidget", e);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
23
src/db/migrations/20200630165247-AddBigBlueButtonWidget.ts
Normal file
23
src/db/migrations/20200630165247-AddBigBlueButtonWidget.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { QueryInterface } from "sequelize";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
up: (queryInterface: QueryInterface) => {
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => queryInterface.bulkInsert("dimension_widgets", [
|
||||||
|
{
|
||||||
|
type: "bigbluebutton",
|
||||||
|
name: "BigBlueButton",
|
||||||
|
avatarUrl: "/img/avatars/bigbluebutton.png",
|
||||||
|
isEnabled: true,
|
||||||
|
isPublic: true,
|
||||||
|
description: "Embed a BigBlueButton conference",
|
||||||
|
}
|
||||||
|
]));
|
||||||
|
},
|
||||||
|
down: (queryInterface: QueryInterface) => {
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => queryInterface.bulkDelete("dimension_widgets", {
|
||||||
|
type: "bigbluebutton",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
7
src/models/Widget.ts
Normal file
7
src/models/Widget.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export interface BigBlueButtonJoinRequest {
|
||||||
|
// A URL supplied by greenlight, BigBlueButton's nice UI project that is itself
|
||||||
|
// a BigBlueButton client
|
||||||
|
greenlightUrl: string;
|
||||||
|
// The name the user wishes to join the meeting with
|
||||||
|
fullName: string;
|
||||||
|
}
|
4
src/models/WidgetResponses.ts
Normal file
4
src/models/WidgetResponses.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface BigBlueButtonJoinResponse {
|
||||||
|
// The meeting URL the client should load to join the meeting
|
||||||
|
url: string;
|
||||||
|
}
|
@ -118,6 +118,9 @@ import { CKEditorModule } from "@ckeditor/ckeditor5-angular";
|
|||||||
import { AdminNewEditTermsComponent } from "./admin/terms/new-edit/new-edit.component";
|
import { AdminNewEditTermsComponent } from "./admin/terms/new-edit/new-edit.component";
|
||||||
import { AdminTermsNewEditPublishDialogComponent } from "./admin/terms/new-edit/publish/publish.component";
|
import { AdminTermsNewEditPublishDialogComponent } from "./admin/terms/new-edit/publish/publish.component";
|
||||||
import { TermsWidgetWrapperComponent } from "./widget-wrappers/terms/terms.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({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@ -147,7 +150,9 @@ import { TermsWidgetWrapperComponent } from "./widget-wrappers/terms/terms.compo
|
|||||||
FullscreenButtonComponent,
|
FullscreenButtonComponent,
|
||||||
VideoWidgetWrapperComponent,
|
VideoWidgetWrapperComponent,
|
||||||
JitsiWidgetWrapperComponent,
|
JitsiWidgetWrapperComponent,
|
||||||
|
BigBlueButtonWidgetWrapperComponent,
|
||||||
GCalWidgetWrapperComponent,
|
GCalWidgetWrapperComponent,
|
||||||
|
BigBlueButtonConfigComponent,
|
||||||
RiotHomeComponent,
|
RiotHomeComponent,
|
||||||
IboxComponent,
|
IboxComponent,
|
||||||
ConfigScreenWidgetComponent,
|
ConfigScreenWidgetComponent,
|
||||||
@ -234,6 +239,7 @@ import { TermsWidgetWrapperComponent } from "./widget-wrappers/terms/terms.compo
|
|||||||
AdminStickersApiService,
|
AdminStickersApiService,
|
||||||
MediaService,
|
MediaService,
|
||||||
StickerApiService,
|
StickerApiService,
|
||||||
|
BigBlueButtonApiService,
|
||||||
AdminTelegramApiService,
|
AdminTelegramApiService,
|
||||||
TelegramApiService,
|
TelegramApiService,
|
||||||
AdminWebhooksApiService,
|
AdminWebhooksApiService,
|
||||||
|
@ -2,6 +2,8 @@ import { RouterModule, Routes } from "@angular/router";
|
|||||||
import { HomeComponent } from "./home/home.component";
|
import { HomeComponent } from "./home/home.component";
|
||||||
import { RiotComponent } from "./riot/riot.component";
|
import { RiotComponent } from "./riot/riot.component";
|
||||||
import { GenericWidgetWrapperComponent } from "./widget-wrappers/generic/generic.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 { VideoWidgetWrapperComponent } from "./widget-wrappers/video/video.component";
|
||||||
import { JitsiWidgetWrapperComponent } from "./widget-wrappers/jitsi/jitsi.component";
|
import { JitsiWidgetWrapperComponent } from "./widget-wrappers/jitsi/jitsi.component";
|
||||||
import { GCalWidgetWrapperComponent } from "./widget-wrappers/gcal/gcal.component";
|
import { GCalWidgetWrapperComponent } from "./widget-wrappers/gcal/gcal.component";
|
||||||
@ -180,6 +182,11 @@ const routes: Routes = [
|
|||||||
component: CustomWidgetConfigComponent,
|
component: CustomWidgetConfigComponent,
|
||||||
data: {breadcrumb: "Custom Widgets", name: "Custom Widgets"},
|
data: {breadcrumb: "Custom Widgets", name: "Custom Widgets"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "bigbluebutton",
|
||||||
|
component: BigBlueButtonConfigComponent,
|
||||||
|
data: {breadcrumb: "BigBlueButton Widgets", name: "BigBlueButton Widgets"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "etherpad",
|
path: "etherpad",
|
||||||
component: EtherpadWidgetConfigComponent,
|
component: EtherpadWidgetConfigComponent,
|
||||||
@ -286,6 +293,7 @@ const routes: Routes = [
|
|||||||
{path: "generic", component: GenericWidgetWrapperComponent},
|
{path: "generic", component: GenericWidgetWrapperComponent},
|
||||||
{path: "video", component: VideoWidgetWrapperComponent},
|
{path: "video", component: VideoWidgetWrapperComponent},
|
||||||
{path: "jitsi", component: JitsiWidgetWrapperComponent},
|
{path: "jitsi", component: JitsiWidgetWrapperComponent},
|
||||||
|
{path: "bigbluebutton", component: BigBlueButtonWidgetWrapperComponent},
|
||||||
{path: "gcal", component: GCalWidgetWrapperComponent},
|
{path: "gcal", component: GCalWidgetWrapperComponent},
|
||||||
{path: "stickerpicker", component: StickerPickerWidgetWrapperComponent},
|
{path: "stickerpicker", component: StickerPickerWidgetWrapperComponent},
|
||||||
{path: "generic-fullscreen", component: GenericFullscreenWidgetWrapperComponent},
|
{path: "generic-fullscreen", component: GenericFullscreenWidgetWrapperComponent},
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
<my-widget-config [widgetComponent]="this">
|
||||||
|
<ng-template #widgetParamsTemplate let-widget="widget">
|
||||||
|
<label class="label-block">
|
||||||
|
BigBlueButton Meeting URL
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
placeholder="https://bbb.example.com/abc-def-ghi"
|
||||||
|
[(ngModel)]="widget.dimension.newData.conferenceUrl" name="widget-url-{{widget.id}}"
|
||||||
|
[disabled]="isUpdating"/>
|
||||||
|
</label>
|
||||||
|
</ng-template>
|
||||||
|
</my-widget-config>
|
@ -0,0 +1,53 @@
|
|||||||
|
import { WidgetComponent, DISABLE_AUTOMATIC_WRAPPING } from "../widget.component";
|
||||||
|
import { WIDGET_BIGBLUEBUTTON, EditableWidget } from "../../../shared/models/widget";
|
||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { FE_BigBlueButtonWidget } from "../../../shared/models/integration";
|
||||||
|
import { SessionStorage } from "../../../shared/SessionStorage";
|
||||||
|
import * as url from "url";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: "bigbluebutton.widget.component.html",
|
||||||
|
styleUrls: ["bigbluebutton.widget.component.scss"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Configuration of BigBlueButton widgets
|
||||||
|
export class BigBlueButtonConfigComponent extends WidgetComponent {
|
||||||
|
private bigBlueButtonWidget: FE_BigBlueButtonWidget = <FE_BigBlueButtonWidget>SessionStorage.editIntegration;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(WIDGET_BIGBLUEBUTTON, "BigBlueButton Conference", DISABLE_AUTOMATIC_WRAPPING);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected OnWidgetsDiscovered(widgets: EditableWidget[]) {
|
||||||
|
for (const widget of widgets) {
|
||||||
|
widget.data.conferenceUrl = this.templateUrl(widget.url, widget.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected OnNewWidgetPrepared(widget: EditableWidget): void {
|
||||||
|
widget.dimension.newData["conferenceUrl"] = this.bigBlueButtonWidget.options.conferenceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected OnWidgetBeforeAdd(widget: EditableWidget) {
|
||||||
|
this.setWidgetOptions(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected OnWidgetBeforeEdit(widget: EditableWidget) {
|
||||||
|
this.setWidgetOptions(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setWidgetOptions(widget: EditableWidget) {
|
||||||
|
widget.dimension.newData.url = widget.dimension.newData.conferenceUrl;
|
||||||
|
|
||||||
|
let widgetQueryString = url.format({
|
||||||
|
query: {
|
||||||
|
"conferenceUrl": "$conferenceUrl",
|
||||||
|
"displayName": "$matrix_display_name",
|
||||||
|
"avatarUrl": "$matrix_avatar_url",
|
||||||
|
"userId": "$matrix_user_id",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
widgetQueryString = this.decodeParams(widgetQueryString, Object.keys(widget.dimension.newData).map(k => "$" + k));
|
||||||
|
widget.dimension.newUrl = window.location.origin + "/widgets/bigbluebutton" + widgetQueryString;
|
||||||
|
}
|
||||||
|
}
|
@ -69,6 +69,10 @@
|
|||||||
<img src="/img/avatars/googlecalendar.png">
|
<img src="/img/avatars/googlecalendar.png">
|
||||||
<span>Google Calendar</span>
|
<span>Google Calendar</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="integration">
|
||||||
|
<img src="/img/avatars/bigbluebutton.png">
|
||||||
|
<span>BigBlueButton</span>
|
||||||
|
</div>
|
||||||
<div class="integration">
|
<div class="integration">
|
||||||
<img src="/img/avatars/customwidget.png">
|
<img src="/img/avatars/customwidget.png">
|
||||||
<span>Custom Widget</span>
|
<span>Custom Widget</span>
|
||||||
|
@ -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 {
|
export interface FE_StickerConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
stickerBot: string;
|
stickerBot: string;
|
||||||
@ -88,6 +93,12 @@ export interface FE_JitsiWidget extends FE_Widget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FE_BigBlueButtonWidget extends FE_Widget {
|
||||||
|
options: {
|
||||||
|
conferenceUrl: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface FE_IntegrationRequirement {
|
export interface FE_IntegrationRequirement {
|
||||||
condition: "publicRoom" | "canSendEventTypes" | "userInRoom";
|
condition: "publicRoom" | "canSendEventTypes" | "userInRoom";
|
||||||
argument: any;
|
argument: any;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { WidgetsResponse } from "./server-client-responses";
|
import { WidgetsResponse } from "./server-client-responses";
|
||||||
|
|
||||||
export const WIDGET_CUSTOM = ["m.custom", "customwidget", "dimension-customwidget"];
|
export const WIDGET_CUSTOM = ["m.custom", "customwidget", "dimension-customwidget"];
|
||||||
|
export const WIDGET_BIGBLUEBUTTON = ["bigbluebutton", "dimension-bigbluebutton"];
|
||||||
export const WIDGET_ETHERPAD = ["m.etherpad", "etherpad", "dimension-etherpad"];
|
export const WIDGET_ETHERPAD = ["m.etherpad", "etherpad", "dimension-etherpad"];
|
||||||
export const WIDGET_GOOGLE_DOCS = ["m.googledoc", "googledocs", "dimension-googledocs"];
|
export const WIDGET_GOOGLE_DOCS = ["m.googledoc", "googledocs", "dimension-googledocs"];
|
||||||
export const WIDGET_GOOGLE_CALENDAR = ["m.googlecalendar", "googlecalendar", "dimension-googlecalendar"];
|
export const WIDGET_GOOGLE_CALENDAR = ["m.googlecalendar", "googlecalendar", "dimension-googlecalendar"];
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
import {
|
import {
|
||||||
WIDGET_CUSTOM,
|
WIDGET_CUSTOM,
|
||||||
|
WIDGET_BIGBLUEBUTTON,
|
||||||
WIDGET_ETHERPAD,
|
WIDGET_ETHERPAD,
|
||||||
WIDGET_GOOGLE_CALENDAR,
|
WIDGET_GOOGLE_CALENDAR,
|
||||||
WIDGET_GOOGLE_DOCS,
|
WIDGET_GOOGLE_DOCS,
|
||||||
@ -35,6 +36,9 @@ export class IntegrationsRegistry {
|
|||||||
"custom": {
|
"custom": {
|
||||||
types: WIDGET_CUSTOM,
|
types: WIDGET_CUSTOM,
|
||||||
},
|
},
|
||||||
|
"bigbluebutton": {
|
||||||
|
types: WIDGET_BIGBLUEBUTTON,
|
||||||
|
},
|
||||||
"youtube": {
|
"youtube": {
|
||||||
types: WIDGET_YOUTUBE
|
types: WIDGET_YOUTUBE
|
||||||
},
|
},
|
||||||
|
@ -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<FE_BigBlueButtonJoin|ApiError> {
|
||||||
|
return this.authedGet<FE_BigBlueButtonJoin|ApiError>("/api/v1/dimension/bigbluebutton/join", {greenlightUrl: url, fullName: name}).toPromise();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
<iframe *ngIf="embedUrl"
|
||||||
|
id="bigBlueButtonContainer"
|
||||||
|
[src]="embedUrl"
|
||||||
|
(load)="onIframeLoad()"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
allow="camera; microphone; encrypted-media; autoplay;"
|
||||||
|
></iframe>
|
||||||
|
|
||||||
|
<div *ngIf="!embedUrl" class="join-conference-wrapper">
|
||||||
|
<div class="join-conference-boat">
|
||||||
|
<div *ngIf="statusMessage; else joinMeetingPrompt" class="join-conference-prompt">
|
||||||
|
<h4 [innerHTML]="statusMessage"></h4>
|
||||||
|
</div>
|
||||||
|
<ng-template #joinMeetingPrompt>
|
||||||
|
<div class="join-conference-prompt">
|
||||||
|
<h3>BigBlueButton Conference</h3>
|
||||||
|
<button type="button" (click)="joinConference()" class="btn btn-primary btn-large">
|
||||||
|
Join Conference
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
152
web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts
Normal file
152
web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -16,7 +16,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: themed(jitsiWelcomeBgColor);
|
background-color: themed(widgetWelcomeBgColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
.join-conference-boat {
|
.join-conference-boat {
|
||||||
|
BIN
web/public/img/avatars/bigbluebutton.png
Normal file
BIN
web/public/img/avatars/bigbluebutton.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
@ -48,7 +48,7 @@ $theme_dark: (
|
|||||||
stickerPickerStickerBgColor: #fff,
|
stickerPickerStickerBgColor: #fff,
|
||||||
stickerPickerShadowColor: hsla(0, 0%, 0%, 0.2),
|
stickerPickerShadowColor: hsla(0, 0%, 0%, 0.2),
|
||||||
|
|
||||||
jitsiWelcomeBgColor: #fff,
|
widgetWelcomeBgColor: #fff,
|
||||||
|
|
||||||
troubleshooterBgColor: #2d2d2d,
|
troubleshooterBgColor: #2d2d2d,
|
||||||
troubleshooterNeutralColor: rgb(205, 215, 222),
|
troubleshooterNeutralColor: rgb(205, 215, 222),
|
||||||
|
@ -48,7 +48,7 @@ $theme_light: (
|
|||||||
stickerPickerStickerBgColor: #fff,
|
stickerPickerStickerBgColor: #fff,
|
||||||
stickerPickerShadowColor: hsla(0, 0%, 0%, 0.2),
|
stickerPickerShadowColor: hsla(0, 0%, 0%, 0.2),
|
||||||
|
|
||||||
jitsiWelcomeBgColor: #fff,
|
widgetWelcomeBgColor: #fff,
|
||||||
|
|
||||||
troubleshooterBgColor: #fff,
|
troubleshooterBgColor: #fff,
|
||||||
troubleshooterNeutralColor: rgb(205, 215, 222),
|
troubleshooterNeutralColor: rgb(205, 215, 222),
|
||||||
|
Loading…
Reference in New Issue
Block a user