Merge pull request #5309 from tsiegleauq/integrate-jitsi-meet-client

Integrate jitsi-meet in OpenSlides
This commit is contained in:
Emanuel Schütze 2020-04-30 13:05:12 +02:00 committed by GitHub
commit a47285c0ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 720 additions and 6 deletions

View File

@ -43,7 +43,7 @@
} }
], ],
"styles": ["src/styles.scss"], "styles": ["src/styles.scss"],
"scripts": ["node_modules/tinymce/tinymce.min.js"], "scripts": ["node_modules/tinymce/tinymce.min.js", "src/assets/jitsi/external_api.js"],
"webWorkerTsConfig": "tsconfig.worker.json" "webWorkerTsConfig": "tsconfig.worker.json"
}, },
"configurations": { "configurations": {

View File

@ -0,0 +1,149 @@
<div class="jitsi-integration">
<div
class="jitsi-bar"
[ngClass]="{
'cdk-visually-hidden': !enableJitsi,
'cast-shadow': !showJitsiWindow
}"
>
<span
class="control-icon-wrapper apply-theme"
[ngClass]="{
'cast-shadow': showJitsiWindow
}"
>
<!-- mute/unmute button -->
<button
class="indicator quick-icon"
mat-mini-fab
*ngIf="isJoined"
(click)="toggleMute()"
matTooltip="{{ 'mute/unmute' | translate }}"
>
<mat-icon color="{{ muted ? 'primary' : 'warn' }}">{{ muted ? 'moff' : 'mic' }}</mat-icon>
</button>
<!-- disconnected icon -->
<mat-icon class="indicator" *ngIf="!isJoined">cloud_off</mat-icon>
<!-- hide unhide video -->
<!-- <button class="quick-icon" mat-mini-fab [disabled]="!isJoined">
<mat-icon *ngIf="isJoined" color="primary">videocam_off</mat-icon>
</button> -->
</span>
<span
class="list-wrapper apply-theme"
[ngClass]="{
'cast-shadow': showJitsiWindow
}"
>
<!-- <span class="list-wrapper apply-theme regular-shadow"> -->
<!-- open-window button -->
<button class="toggle-list-button" mat-button (click)="toggleShowJitsi()">
<span> {{ 'Audio Conference' | translate }}</span>
<mat-icon class="opened-indicator" *ngIf="!showJitsiWindow">keyboard_arrow_up</mat-icon>
<mat-icon class="opened-indicator" *ngIf="showJitsiWindow">keyboard_arrow_down </mat-icon>
<div class="one-line">
&nbsp;
<span *ngIf="currentDominantSpeaker">
» <span class="dominant-speaker">{{ currentDominantSpeaker.displayName }}</span>
</span>
<span *ngIf="!isJitsiActive">
<i>{{ 'disconnected' | translate }}</i>
</span>
</div>
</button>
<!-- unfolded list -->
<div
class="jitsi-list"
[ngClass]="{
'cdk-visually-hidden': !showJitsiWindow
}"
>
<!-- Jitsi content window -->
<div class="content">
<!-- The "somewhere else active" warning -->
<div class="disconnected" *ngIf="isJitsiActiveInAnotherTab && !isJitsiActive">
<span>{{ 'Another instance of Jitsi is active in you OpenSlides session' | translate }}</span>
<button mat-button color="warn" (click)="forceStart()">
<span>{{ 'Force Jitsi to reload' | translate }}</span>
</button>
</div>
<div class="disconnected" *ngIf="!isJitsiActiveInAnotherTab && !isJitsiActive">
<span>{{ 'disconnected' | translate }}</span>
</div>
<!-- Hidden jitsy container -->
<div [ngStyle]="{ display: 'none' }" #jitsi></div>
<!-- user list -->
<div class="room-members" *ngIf="isJitsiActive">
<div class="member-list">
<ol>
<li
*ngFor="let memberId of memberList; trackBy: trackByIndex"
[ngClass]="{
focused: members[memberId].focus
}"
>
<div class="member-list-entry">
{{ members[memberId].name }}
</div>
</li>
</ol>
</div>
</div>
</div>
<!-- Custom control buttons -->
<div>
<mat-divider></mat-divider>
<div class="control-grid">
<div class="control-buttons">
<!-- Hangup -->
<button
mat-mini-fab
color="warn"
(click)="stopJitsi()"
*ngIf="isJitsiActive && isJoined"
matTooltip="{{ 'Leave' | translate }}"
>
<mat-icon>call_end</mat-icon>
</button>
<!-- Enter jitsi manually -->
<button
mat-mini-fab
color="accent"
(click)="enterConversation()"
[disabled]="isJitsiActive || isJitsiActiveInAnotherTab"
*ngIf="!isJoined"
matTooltip="{{ 'Enter conference' | translate }}"
>
<mat-icon>call</mat-icon>
</button>
</div>
<!-- Open in new tab -->
<button
mat-icon-button
class="open-jitsi-in-tab"
color="accent"
(click)="openExternal()"
matTooltip="{{ 'Open Jitsi in new tab' | translate }}"
[disabled]="isRoomPasswordProtected"
>
<mat-icon>
open_in_new
</mat-icon>
</button>
</div>
</div>
</div>
</span>
</div>
</div>

View File

@ -0,0 +1,116 @@
.jitsi-integration {
.cast-shadow {
box-shadow: -3px -3px 10px 0px rgba(0, 0, 0, 0.2) !important;
}
.jitsi-bar {
z-index: 99;
display: flex;
position: fixed;
right: 20px;
bottom: 0;
$wrapper-padding: 5px;
$bar-height: 40px;
.control-icon-wrapper {
z-index: 1;
min-height: $bar-height;
display: flex;
margin-top: auto;
padding-right: 0.5em;
padding: $wrapper-padding 0 $wrapper-padding $wrapper-padding;
border-top-left-radius: 4px;
.indicator {
width: 40px;
text-align: center;
margin: auto $wrapper-padding auto 0;
}
}
.list-wrapper {
min-height: $bar-height;
padding-top: $wrapper-padding;
border-top-right-radius: 4px;
width: 250px;
max-width: 250px;
.toggle-list-button {
position: relative;
line-height: normal;
width: 100%;
padding: 0 2.5em;
margin-bottom: $wrapper-padding;
font-weight: normal;
text-align: right;
.opened-indicator {
position: absolute;
right: $wrapper-padding;
top: $wrapper-padding;
}
.dominant-speaker {
font-weight: 500;
width: fit-content;
margin: 0 auto;
}
}
.jitsi-list {
.content {
height: 40vh;
clear: both;
.disconnected {
display: flex;
flex-direction: column;
height: inherit;
padding-left: 1em;
padding-right: 1em;
span {
margin: auto;
}
}
.room-members {
height: inherit;
.member-list {
max-height: 100%;
overflow-y: auto;
.member-list-entry {
margin: 5px;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.focused {
font-weight: bold;
}
}
}
.control-grid {
padding: $wrapper-padding 0;
display: grid;
grid-template-areas: 'helper buttons new-tab';
grid-template-columns: 40px auto 40px;
.control-buttons {
grid-area: buttons;
margin: auto;
}
.open-jitsi-in-tab {
grid-area: new-tab;
}
}
}
}
}
}

View File

@ -0,0 +1,33 @@
@import '~@angular/material/theming';
@mixin os-jitsi-theme($theme) {
$primary: map-get($theme, primary);
$accent: map-get($theme, accent);
$warn: map-get($theme, accent);
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
.jitsi-bar {
.apply-theme {
background-color: mat-color($primary);
.quick-icon:not([disabled]) {
background-color: mat-color($primary, default-contrast);
}
.indicator {
color: mat-color($primary, default-contrast);
}
.toggle-list-button {
span {
color: mat-color($primary, default-contrast);
}
}
}
}
.jitsi-list {
background-color: mat-color($background, card);
}
}

View File

@ -0,0 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { JitsiComponent } from './jitsi.component';
describe('JitsiComponent', () => {
let component: JitsiComponent;
let fixture: ComponentFixture<JitsiComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [JitsiComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(JitsiComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,341 @@
import { Component, ElementRef, HostListener, OnDestroy, ViewChild } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateService } from '@ngx-translate/core';
import { distinctUntilChanged } from 'rxjs/operators';
import { BaseComponent } from 'app/base.component';
import { ConstantsService } from 'app/core/core-services/constants.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { Deferred } from 'app/core/promises/deferred';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service';
declare var JitsiMeetExternalAPI: any;
interface JitsiMember {
id: string;
displayName: string;
}
interface ConferenceJoinedResult {
roomName: string;
id: string;
displayName: string;
formattedDisplayName: string;
}
interface DisplayNameChangeResult {
// Yes, in this case "displayname" really does not have a capital n. Thank you jitsi.
displayname: string;
formattedDisplayName: string;
id: string;
}
interface JitsiSettings {
JITSI_DOMAIN: string;
JITSI_ROOM_NAME: string;
JITSI_ROOM_PASSWORD: string;
}
interface ConferenceMember {
name: string;
focus: boolean;
}
@Component({
selector: 'os-jitsi',
templateUrl: './jitsi.component.html',
styleUrls: ['./jitsi.component.scss']
})
export class JitsiComponent extends BaseComponent implements OnDestroy {
public enableJitsi: boolean;
private autoconnect: boolean;
private roomName: string;
private roomPassword: string;
private jitsiDomain: string;
// do not set the password twice
private isPasswortSet = false;
public showJitsiWindow = false;
public muted = true;
@ViewChild('jitsi')
private jitsiNode: ElementRef;
// JitsiMeet api object
private api: any | null;
public get isJitsiActive(): boolean {
return !!this.api;
}
public isJoined: boolean;
private options: object;
private lockLoaded: Deferred<void> = new Deferred();
private constantsLoaded: Deferred<void> = new Deferred();
// storage locks
public isJitsiActiveInAnotherTab: boolean;
private RTC_LOGGED_STORAGE_KEY = 'rtcIsLoggedIn';
// JitsiID to ConferenceMember
public members = {};
public currentDominantSpeaker: JitsiMember;
public get memberList(): string[] {
return Object.keys(this.members);
}
public get isRoomPasswordProtected(): boolean {
return this.roomPassword?.length > 0;
}
private configOverwrite = {
startAudioOnly: true,
// allows jitsi on mobile devices
disableDeepLinking: true,
startWithAudioMuted: true,
startWithVideoMuted: true,
useNicks: true,
enableWelcomePage: false,
enableUserRolesBasedOnToken: false,
enableFeaturesBasedOnToken: false,
disableThirdPartyRequests: true,
enableNoAudioDetection: false,
enableNoisyMicDetection: false
};
private interfaceConfigOverwrite = {
filmStripOnly: true,
INITIAL_TOOLBAR_TIMEOUT: 2000,
TOOLBAR_TIMEOUT: 400,
SHOW_JITSI_WATERMARK: false,
SHOW_WATERMARK_FOR_GUESTS: false,
INVITATION_POWERED_BY: false,
DISABLE_JOIN_LEAVE_NOTIFICATIONS: true,
TOOLBAR_BUTTONS: [],
SETTINGS_SECTIONS: []
};
public constructor(
titleService: Title,
translate: TranslateService,
private operator: OperatorService,
private storageMap: StorageMap,
private userRepo: UserRepositoryService,
private constantsService: ConstantsService,
private configService: ConfigService
) {
super(titleService, translate);
this.setUp();
}
public ngOnDestroy(): void {
this.stopJitsi();
}
// closing the tab should also try to stop jitsi.
// this will usually not be cought by ngOnDestroy
@HostListener('window:beforeunload', ['$event'])
public async beforeunload($event: any): Promise<void> {
await this.stopJitsi();
}
private async setUp(): Promise<void> {
this.storageMap
.watch(this.RTC_LOGGED_STORAGE_KEY)
.pipe(distinctUntilChanged())
.subscribe((inUse: boolean) => {
this.isJitsiActiveInAnotherTab = inUse;
this.lockLoaded.resolve();
if (!inUse && !this.isJitsiActive) {
this.startJitsi();
}
});
await this.lockLoaded;
this.constantsService.get<JitsiSettings>('Settings').subscribe(settings => {
if (settings) {
this.jitsiDomain = settings.JITSI_DOMAIN;
this.roomName = settings.JITSI_ROOM_NAME;
this.roomPassword = settings.JITSI_ROOM_PASSWORD;
this.constantsLoaded.resolve();
}
});
await this.constantsLoaded;
this.configService
.get<boolean>('general_system_conference_show')
.subscribe(autoconnect => (this.autoconnect = autoconnect));
this.configService.get<boolean>('general_system_conference_auto_connect').subscribe(show => {
this.enableJitsi = show && !!this.jitsiDomain && !!this.roomName;
if (this.enableJitsi && this.autoconnect) {
this.startJitsi();
} else {
this.stopJitsi();
}
});
}
public toggleMute(): void {
if (this.isJitsiActive) {
this.api.executeCommand('toggleAudio');
}
}
public async forceStart(): Promise<void> {
await this.deleteJitsiLock();
await this.stopJitsi();
await this.startJitsi();
}
private startJitsi(): void {
if (!this.isJitsiActiveInAnotherTab && this.enableJitsi && !this.isJitsiActive && this.jitsiNode) {
this.enterConversation();
}
}
public async enterConversation(): Promise<void> {
await this.operator.loaded;
this.storageMap.set(this.RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {});
this.setOptions();
this.api = new JitsiMeetExternalAPI(this.jitsiDomain, this.options);
const jitsiname = this.userRepo.getShortName(this.operator.user);
this.api.executeCommand('displayName', jitsiname);
this.loadApiCallbacks();
}
private loadApiCallbacks(): void {
this.api.on('videoConferenceJoined', (info: ConferenceJoinedResult) => {
this.onEnterConference(info);
});
this.api.on('participantJoined', (newMember: JitsiMember) => {
this.addMember(newMember);
});
this.api.on('participantLeft', (oldMember: { id: string }) => {
this.removeMember(oldMember);
});
this.api.on('displayNameChange', (member: DisplayNameChangeResult) => {
this.renameMember(member);
});
this.api.on('audioMuteStatusChanged', (isMuted: { muted: boolean }) => {
this.muted = isMuted.muted;
});
this.api.on('readyToClose', () => {
this.stopJitsi();
});
this.api.on('dominantSpeakerChanged', (newSpeaker: { id: string }) => {
this.newDominantSpeaker(newSpeaker.id);
});
this.api.on('passwordRequired', () => {
this.setRoomPassword();
});
}
private onEnterConference(info: ConferenceJoinedResult): void {
this.isJoined = true;
this.addMember({ displayName: info.displayName, id: info.id });
this.setRoomPassword();
}
private setRoomPassword(): void {
if (this.roomPassword && !this.isPasswortSet) {
// You can only set the password after the server has recognized that you are
// the moderator. There is no event listener for that.
setTimeout(() => {
this.api.executeCommand('password', this.roomPassword);
this.isPasswortSet = true;
}, 1000);
}
}
private newDominantSpeaker(newSpeakerId: string): void {
if (this.currentDominantSpeaker && this.members[this.currentDominantSpeaker.id]) {
this.members[this.currentDominantSpeaker.id].focus = false;
}
this.members[newSpeakerId].focus = true;
this.currentDominantSpeaker = {
id: newSpeakerId,
displayName: this.members[newSpeakerId].name
};
}
private addMember(newMember: JitsiMember): void {
this.members[newMember.id] = {
name: newMember.displayName,
focus: false
} as ConferenceMember;
}
private removeMember(oldMember: { id: string }): void {
if (this.members[oldMember.id]) {
delete this.members[oldMember.id];
}
}
private renameMember(member: DisplayNameChangeResult): void {
if (this.members[member.id]) {
this.members[member.id].name = member.displayname;
}
if (this.currentDominantSpeaker?.id === member.id) {
this.newDominantSpeaker(member.id);
}
}
private clearMembers(): void {
this.members = {};
}
public async stopJitsi(): Promise<void> {
if (this.isJitsiActive) {
this.api.executeCommand('hangup');
this.clearMembers();
await this.deleteJitsiLock();
this.api.dispose();
this.api = undefined;
}
this.isJoined = false;
this.isPasswortSet = false;
this.currentDominantSpeaker = null;
}
private setOptions(): void {
this.options = {
roomName: this.roomName,
parentNode: this.jitsiNode.nativeElement,
configOverwrite: this.configOverwrite,
interfaceConfigOverwrite: this.interfaceConfigOverwrite
};
}
public toggleShowJitsi(): void {
this.showJitsiWindow = !this.showJitsiWindow;
}
private getJitsiMeetUrl(): string {
return `https://${this.jitsiDomain}/${this.roomName}`;
}
public openExternal(): void {
this.stopJitsi();
window.open(this.getJitsiMeetUrl(), '_blank');
}
private async deleteJitsiLock(): Promise<void> {
await this.storageMap.delete(this.RTC_LOGGED_STORAGE_KEY).toPromise();
}
}

View File

@ -10,6 +10,11 @@ $pbl-height: var(--pbl-height);
height: $pbl-height; height: $pbl-height;
} }
// additional space if the jitsi integration is active
.pbl-ngrid-row:last-of-type {
margin-bottom: 30px !important;
}
.pbl-ngrid-cell { .pbl-ngrid-cell {
height: inherit; height: inherit;
} }

View File

@ -126,6 +126,7 @@ import { AssignmentPollDetailContentComponent } from './components/assignment-po
import { GlobalSpinnerComponent } from './components/global-spinner/global-spinner.component'; import { GlobalSpinnerComponent } from './components/global-spinner/global-spinner.component';
import { UserMenuComponent } from './components/user-menu/user-menu.component'; import { UserMenuComponent } from './components/user-menu/user-menu.component';
import { JitsiComponent } from './components/jitsi/jitsi.component';
/** /**
* Share Module for all "dumb" components and pipes. * Share Module for all "dumb" components and pipes.
@ -292,7 +293,8 @@ import { UserMenuComponent } from './components/user-menu/user-menu.component';
PollPercentBasePipe, PollPercentBasePipe,
VotingPrivacyWarningComponent, VotingPrivacyWarningComponent,
MotionPollDetailContentComponent, MotionPollDetailContentComponent,
AssignmentPollDetailContentComponent AssignmentPollDetailContentComponent,
JitsiComponent
], ],
declarations: [ declarations: [
PermsDirective, PermsDirective,
@ -352,7 +354,8 @@ import { UserMenuComponent } from './components/user-menu/user-menu.component';
PollPercentBasePipe, PollPercentBasePipe,
VotingPrivacyWarningComponent, VotingPrivacyWarningComponent,
MotionPollDetailContentComponent, MotionPollDetailContentComponent,
AssignmentPollDetailContentComponent AssignmentPollDetailContentComponent,
JitsiComponent
], ],
providers: [ providers: [
{ {

View File

@ -41,7 +41,7 @@
<os-grid-layout *ngIf="projector"> <os-grid-layout *ngIf="projector">
<os-tile [preferredSize]="projectorTileSizeLeft"> <os-tile [preferredSize]="projectorTileSizeLeft">
<div *ngIf="projector" class="projector-detail-wrapper column-left"> <div *ngIf="projector" class="projector-detail-wrapper column-left">
<a [routerLink]="['/projector', projector.id]"> <a [routerLink]="['/projector', projector.id]" target="_blank">
<div id="projector"> <div id="projector">
<os-projector [projector]="projector"></os-projector> <os-projector [projector]="projector"></os-projector>
</div> </div>

View File

@ -24,7 +24,7 @@
</button> </button>
</ng-container> </ng-container>
<ng-container class="meta-text-block-content"> <ng-container class="meta-text-block-content">
<a class="no-markup" [routerLink]="getDetailLink()"> <a class="no-markup" [routerLink]="getDetailLink()" [target]="projectionTarget">
<div class="projector"> <div class="projector">
<os-projector [projector]="projector"></os-projector> <os-projector [projector]="projector"></os-projector>
</div> </div>

View File

@ -35,6 +35,14 @@ export class ProjectorListEntryComponent extends BaseViewComponent implements On
return this._projector; return this._projector;
} }
public get projectionTarget(): '_blank' | '_self' {
if (this.operator.hasPerms('core.can_manage_projector')) {
return '_self';
} else {
return '_blank';
}
}
private _projector: ViewProjector; private _projector: ViewProjector;
/** /**

View File

@ -89,6 +89,7 @@
<mat-icon>arrow_forward_ios</mat-icon> <mat-icon>arrow_forward_ios</mat-icon>
</button> </button>
</div> </div>
<os-jitsi></os-jitsi>
<div (touchstart)="swipe($event, 'start')" (touchend)="swipe($event, 'end')" class="content"> <div (touchstart)="swipe($event, 'start')" (touchend)="swipe($event, 'end')" class="content">
<main> <main>
<router-outlet #o="outlet"></router-outlet> <router-outlet #o="outlet"></router-outlet>

File diff suppressed because one or more lines are too long

View File

@ -32,6 +32,7 @@
@import './app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss'; @import './app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss';
@import './app/site/assignments/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss'; @import './app/site/assignments/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss';
@import './app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss'; @import './app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss';
@import './app/shared/components/jitsi/jitsi.component.scss-theme.scss';
/** fonts */ /** fonts */
@import './assets/styles/fonts.scss'; @import './assets/styles/fonts.scss';
@ -64,6 +65,7 @@ $narrow-spacing: (
@include os-motion-poll-detail-style($theme); @include os-motion-poll-detail-style($theme);
@include os-assignment-poll-detail-style($theme); @include os-assignment-poll-detail-style($theme);
@include os-progress-snack-bar-style($theme); @include os-progress-snack-bar-style($theme);
@include os-jitsi-theme($theme);
} }
/** Load projector specific SCSS values */ /** Load projector specific SCSS values */
@ -335,9 +337,15 @@ b,
text-align: center; text-align: center;
color: gray; color: gray;
} }
mat-card {
.regular-shadow {
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.37) !important; box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.37) !important;
} }
.mat-card {
@extend .regular-shadow;
}
.os-card { .os-card {
max-width: 770px; max-width: 770px;
margin-top: 20px !important; margin-top: 20px !important;

View File

@ -145,6 +145,9 @@ class CoreAppConfig(AppConfig):
"PING_INTERVAL", "PING_INTERVAL",
"PING_TIMEOUT", "PING_TIMEOUT",
"ENABLE_ELECTRONIC_VOTING", "ENABLE_ELECTRONIC_VOTING",
"JITSI_DOMAIN",
"JITSI_ROOM_NAME",
"JITSI_ROOM_PASSWORD",
] ]
client_settings_dict = {} client_settings_dict = {}
for key in client_settings_keys: for key in client_settings_keys:

View File

@ -122,6 +122,24 @@ def get_config_variables():
subgroup="System", subgroup="System",
) )
yield ConfigVariable(
name="general_system_conference_auto_connect",
default_value=False,
input_type="boolean",
label="Show the audio conferece integration at the bottom right corner",
weight=142,
subgroup="System",
)
yield ConfigVariable(
name="general_system_conference_show",
default_value=False,
input_type="boolean",
label="Allow users to automatically connect to the audio conference",
weight=143,
subgroup="System",
)
# General export settings # General export settings
yield ConfigVariable( yield ConfigVariable(