MOBILE-3889 settings: Add developer options page
parent
fbf495ca1b
commit
f9f60414bb
|
@ -2090,10 +2090,12 @@
|
|||
"core.settings.debugdisplaydescription": "local_moodlemobileapp",
|
||||
"core.settings.deletesitefiles": "local_moodlemobileapp",
|
||||
"core.settings.deletesitefilestitle": "local_moodlemobileapp",
|
||||
"core.settings.developeroptions": "local_moodlemobileapp",
|
||||
"core.settings.deviceinfo": "local_moodlemobileapp",
|
||||
"core.settings.deviceos": "local_moodlemobileapp",
|
||||
"core.settings.disableall": "message",
|
||||
"core.settings.disabled": "lesson",
|
||||
"core.settings.disabledfeatures": "tool_mobile",
|
||||
"core.settings.displayformat": "local_moodlemobileapp",
|
||||
"core.settings.enabledownloadsection": "local_moodlemobileapp",
|
||||
"core.settings.enablefirebaseanalytics": "local_moodlemobileapp",
|
||||
|
@ -2109,6 +2111,7 @@
|
|||
"core.settings.fontsize": "local_moodlemobileapp",
|
||||
"core.settings.fontsizecharacter": "block_accessibility/char",
|
||||
"core.settings.forcedsetting": "local_moodlemobileapp",
|
||||
"core.settings.forcesafeareamargins": "local_moodlemobileapp",
|
||||
"core.settings.general": "moodle",
|
||||
"core.settings.helpusimprove": "local_moodlemobileapp",
|
||||
"core.settings.ioscookies": "local_moodlemobileapp",
|
||||
|
@ -2124,15 +2127,18 @@
|
|||
"core.settings.navigatoruseragent": "local_moodlemobileapp",
|
||||
"core.settings.networkstatus": "local_moodlemobileapp",
|
||||
"core.settings.opensourcelicenses": "local_moodlemobileapp",
|
||||
"core.settings.pluginstyles": "local_moodlemobileapp",
|
||||
"core.settings.preferences": "moodle",
|
||||
"core.settings.privacypolicy": "local_moodlemobileapp",
|
||||
"core.settings.publisher": "local_moodlemobileapp",
|
||||
"core.settings.pushid": "local_moodlemobileapp",
|
||||
"core.settings.remotestyles": "local_moodlemobileapp",
|
||||
"core.settings.reportinbackground": "local_moodlemobileapp",
|
||||
"core.settings.screen": "local_moodlemobileapp",
|
||||
"core.settings.settings": "moodle",
|
||||
"core.settings.showdownloadoptions": "local_moodlemobileapp",
|
||||
"core.settings.siteinfo": "local_moodlemobileapp",
|
||||
"core.settings.siteplugins": "local_moodlemobileapp",
|
||||
"core.settings.sites": "moodle",
|
||||
"core.settings.spaceusage": "local_moodlemobileapp",
|
||||
"core.settings.spaceusagehelp": "local_moodlemobileapp",
|
||||
|
@ -2140,8 +2146,10 @@
|
|||
"core.settings.synchronizenow": "local_moodlemobileapp",
|
||||
"core.settings.synchronizenowhelp": "local_moodlemobileapp",
|
||||
"core.settings.syncsettings": "local_moodlemobileapp",
|
||||
"core.settings.textdirection": "local_moodlemobileapp",
|
||||
"core.settings.total": "moodle",
|
||||
"core.settings.wificonnection": "local_moodlemobileapp",
|
||||
"core.settings.youradev": "local_moodlemobileapp",
|
||||
"core.sharedfiles.chooseaccountstorefile": "local_moodlemobileapp",
|
||||
"core.sharedfiles.chooseactionrepeatedfile": "local_moodlemobileapp",
|
||||
"core.sharedfiles.errorreceivefilenosites": "local_moodlemobileapp",
|
||||
|
|
|
@ -22,10 +22,12 @@
|
|||
"debugdisplaydescription": "If enabled, error modals will display more data about the error if possible.",
|
||||
"deletesitefiles": "Are you sure that you want to delete the downloaded files and cached data from the site '{{sitename}}'? You won't be able to use the app in offline mode.",
|
||||
"deletesitefilestitle": "Delete site files",
|
||||
"developeroptions": "Developer options",
|
||||
"deviceinfo": "Device info",
|
||||
"deviceos": "Device OS",
|
||||
"disableall": "Disable notifications",
|
||||
"disabled": "Disabled",
|
||||
"disabledfeatures": "Disabled features",
|
||||
"displayformat": "Display format",
|
||||
"enabledownloadsection": "Enable download sections",
|
||||
"enablefirebaseanalytics": "Enable Firebase analytics",
|
||||
|
@ -41,6 +43,7 @@
|
|||
"fontsize": "Text size",
|
||||
"fontsizecharacter": "A",
|
||||
"forcedsetting": "This setting has been forced by your site configuration.",
|
||||
"forcesafeareamargins": "Force safe area margins",
|
||||
"general": "General",
|
||||
"helpusimprove": "Help us improve this app",
|
||||
"ioscookies": "Cross-Website Tracking",
|
||||
|
@ -56,15 +59,18 @@
|
|||
"navigatoruseragent": "Navigator userAgent",
|
||||
"networkstatus": "Internet connection status",
|
||||
"opensourcelicenses": "Open Source Licences",
|
||||
"pluginstyles": "Enable site plugin styles",
|
||||
"preferences": "Preferences",
|
||||
"privacypolicy": "Privacy policy",
|
||||
"publisher": "Publisher",
|
||||
"pushid": "Push notifications ID",
|
||||
"remotestyles": "Enable remote styles",
|
||||
"reportinbackground": "Report errors automatically",
|
||||
"screen": "Screen information",
|
||||
"settings": "Settings",
|
||||
"showdownloadoptions": "Show download options",
|
||||
"siteinfo": "Site info",
|
||||
"siteplugins": "Site plugins",
|
||||
"sites": "Sites",
|
||||
"spaceusage": "Space usage",
|
||||
"spaceusagehelp": "Deleting the stored information of the site will remove all the site offline data. This information allows you to use the app when offline. ",
|
||||
|
@ -72,6 +78,8 @@
|
|||
"synchronizenow": "Synchronise now",
|
||||
"synchronizenowhelp": "Synchronising a site will send pending changes and all offline activity stored in the device and will synchronise some data like messages and notifications.",
|
||||
"syncsettings": "Synchronisation settings",
|
||||
"textdirection": "Text direction",
|
||||
"total": "Total",
|
||||
"wificonnection": "Wi-Fi connection"
|
||||
"wificonnection": "Wi-Fi connection",
|
||||
"youradev": "You are now a developer"
|
||||
}
|
||||
|
|
|
@ -11,12 +11,9 @@
|
|||
|
||||
<ion-content>
|
||||
<ion-list>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label><h2>{{ appName }} {{ versionName }}</h2></ion-label>
|
||||
</ion-item>
|
||||
<ion-item button class="ion-text-wrap" (click)="openPage('deviceinfo')" detail="true">
|
||||
<ion-item button class="ion-text-wrap" detail="false" (click)="openPage('deviceinfo')">
|
||||
<ion-icon name="fas-mobile" slot="start" aria-hidden="true"></ion-icon>
|
||||
<ion-label>{{ 'core.settings.deviceinfo' | translate }}</ion-label>
|
||||
<ion-label><h2>{{ appName }} {{ versionName }}</h2></ion-label>
|
||||
</ion-item>
|
||||
<ion-item button class="ion-text-wrap" (click)="openPage('licenses')" detail="true">
|
||||
<ion-icon name="far-copyright" slot="start" aria-hidden="true"></ion-icon>
|
||||
|
|
|
@ -49,8 +49,6 @@ export class CoreSettingsAboutPage {
|
|||
* @param page The component deeplink name you want to push onto the navigation stack.
|
||||
*/
|
||||
openPage(page: string): void {
|
||||
// const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
|
||||
// navCtrl.push(page);
|
||||
CoreNavigator.navigate(page);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
|
||||
<h1>{{ 'core.settings.developeroptions' | translate }}</h1>
|
||||
|
||||
<ion-buttons slot="end" *ngIf="siteId">
|
||||
<ion-button fill="clear" (click)="copyInfo()" [attr.aria-label]="'core.settings.copyinfo' | translate">
|
||||
<ion-icon slot="icon-only" name="fas-clipboard" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-list>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<h2>{{ 'core.settings.textdirection' | translate }}</h2>
|
||||
<p>{{ direction }}</p>
|
||||
</ion-label>
|
||||
<ion-toggle [(ngModel)]="rtl" (ionChange)="RTLChanged()"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<h2>{{ 'core.settings.forcesafeareamargins' | translate }}</h2>
|
||||
</ion-label>
|
||||
<ion-toggle [(ngModel)]="forceSafeAreaMargins" (ionChange)="safeAreaChanged()"></ion-toggle>
|
||||
</ion-item>
|
||||
<ng-container *ngIf="siteId">
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<h2>{{ 'core.settings.remotestyles' | translate }} <ion-badge>{{remoteStylesCount}}</ion-badge></h2>
|
||||
</ion-label>
|
||||
<ion-toggle [(ngModel)]="remoteStyles" (ionChange)="remoteStylesChanged()"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<h2>{{ 'core.settings.pluginstyles' | translate }} <ion-badge>{{pluginStylesCount}}</ion-badge></h2>
|
||||
</ion-label>
|
||||
<ion-toggle [(ngModel)]="pluginStyles" (ionChange)="pluginStylesChanged()"></ion-toggle>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider><ion-label><h2>{{ 'core.settings.disabledfeatures' | translate }}</h2></ion-label></ion-item-divider>
|
||||
<ion-item class="ion-text-wrap" *ngFor="let feature of disabledFeatures">
|
||||
<ion-label>
|
||||
<h2>{{ feature }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider><ion-label><h2>{{ 'core.settings.siteplugins' | translate }}</h2></ion-label></ion-item-divider>
|
||||
<ion-item class="ion-text-wrap" *ngFor="let plugin of sitePlugins">
|
||||
<ion-label>
|
||||
<h2>{{ plugin.addon }} ({{plugin.component}})</h2>
|
||||
<p>{{plugin.version}}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
</ion-content>
|
|
@ -0,0 +1,149 @@
|
|||
// (C) Copyright 2015 Moodle Pty 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 { Component, OnInit } from '@angular/core';
|
||||
import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { Platform } from '@singletons';
|
||||
|
||||
/**
|
||||
* Page that displays the developer options.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-core-app-settings-dev',
|
||||
templateUrl: 'dev.html',
|
||||
})
|
||||
export class CoreSettingsDevPage implements OnInit {
|
||||
|
||||
rtl = false;
|
||||
forceSafeAreaMargins = false;
|
||||
direction = 'ltr';
|
||||
|
||||
remoteStyles = true;
|
||||
remoteStylesCount = 0;
|
||||
pluginStyles = true;
|
||||
pluginStylesCount = 0;
|
||||
sitePlugins: CoreSitePluginsBasicInfo[] = [];
|
||||
|
||||
disabledFeatures: string[] = [];
|
||||
|
||||
siteId: string | undefined;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.rtl = Platform.isRTL;
|
||||
this.RTLChanged();
|
||||
|
||||
this.forceSafeAreaMargins = document.documentElement.classList.contains('force-safe-area-margins');
|
||||
this.safeAreaChanged();
|
||||
|
||||
this.siteId = CoreSites.getCurrentSite()?.getId();
|
||||
|
||||
if (!this.siteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.remoteStyles = false;
|
||||
this.remoteStylesCount = 0;
|
||||
|
||||
this.pluginStyles = false;
|
||||
this.pluginStylesCount = 0;
|
||||
|
||||
document.head.querySelectorAll('style').forEach((style) => {
|
||||
if (this.siteId && style.id.endsWith(this.siteId)) {
|
||||
if (style.innerHTML.length > 0) {
|
||||
this.remoteStylesCount++;
|
||||
}
|
||||
this.remoteStyles = this.remoteStyles || style.getAttribute('media') != 'disabled';
|
||||
}
|
||||
|
||||
if (style.id.startsWith('siteplugin-')) {
|
||||
if (style.innerHTML.length > 0) {
|
||||
this.pluginStylesCount++;
|
||||
}
|
||||
this.pluginStyles = this.pluginStyles || style.getAttribute('media') != 'disabled';
|
||||
}
|
||||
});
|
||||
|
||||
this.sitePlugins = CoreSitePlugins.getCurrentSitePluginList().map((plugin) => ({
|
||||
addon: plugin.addon,
|
||||
component: plugin.component,
|
||||
version: plugin.version,
|
||||
}));
|
||||
|
||||
const disabledFeatures = (await CoreSites.getCurrentSite()?.getPublicConfig())?.tool_mobile_disabledfeatures;
|
||||
|
||||
this.disabledFeatures = disabledFeatures?.split(',') || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the rtl is enabled or disabled.
|
||||
*/
|
||||
RTLChanged(): void {
|
||||
this.direction = this.rtl ? 'rtl' : 'ltr';
|
||||
document.dir = this.direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when safe area margins is enabled or disabled.
|
||||
*/
|
||||
safeAreaChanged(): void {
|
||||
document.documentElement.classList.toggle('force-safe-area-margins', this.forceSafeAreaMargins);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when remote styles is enabled or disabled.
|
||||
*/
|
||||
remoteStylesChanged(): void {
|
||||
document.head.querySelectorAll('style').forEach((style) => {
|
||||
if (this.siteId && style.id.endsWith(this.siteId)) {
|
||||
if (this.remoteStyles) {
|
||||
style.removeAttribute('media');
|
||||
} else {
|
||||
style.setAttribute('media', 'disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when remote styles is enabled or disabled.
|
||||
*/
|
||||
pluginStylesChanged(): void {
|
||||
document.head.querySelectorAll('style').forEach((style) => {
|
||||
if (style.id.startsWith('siteplugin-')) {
|
||||
if (this.pluginStyles) {
|
||||
style.removeAttribute('media');
|
||||
} else {
|
||||
style.setAttribute('media', 'disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies site info.
|
||||
*/
|
||||
copyInfo(): void {
|
||||
CoreUtils.copyToClipboard(JSON.stringify({ disabledFeatures: this.disabledFeatures, sitePlugins: this.sitePlugins }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Basic site plugin info.
|
||||
type CoreSitePluginsBasicInfo = {
|
||||
component: string;
|
||||
addon: string;
|
||||
version: string;
|
||||
};
|
|
@ -16,13 +16,19 @@
|
|||
|
||||
<ion-content>
|
||||
<ion-list>
|
||||
<ion-item *ngIf="showDevOptions" detail="true" (click)="gotoDevOptions()">
|
||||
<ion-icon name="fas-terminal" slot="start" aria-hidden="true"></ion-icon>
|
||||
<ion-label class="ion-text-wrap">
|
||||
{{ 'core.settings.developeroptions' | translate }}
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item (longPress)="copyItemInfo($event)">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2>{{ 'core.settings.appversion' | translate}}</h2>
|
||||
<p>{{ deviceInfo.versionName }} ({{ deviceInfo.versionCode }})</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item (longPress)="copyItemInfo($event)">
|
||||
<ion-item (longPress)="copyItemInfo($event)" (click)="enableDevOptions()">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2>{{ 'core.settings.compilationinfo' | translate }}</h2>
|
||||
<p *ngIf="deviceInfo.compilationTime">{{ deviceInfo.compilationTime | coreFormatDate: "LLL Z": false }}</p>
|
||||
|
|
|
@ -17,12 +17,17 @@ import { RouterModule, Routes } from '@angular/router';
|
|||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { CoreSettingsDeviceInfoPage } from './deviceinfo';
|
||||
import { CoreSettingsDevPage } from '../dev/dev';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: CoreSettingsDeviceInfoPage,
|
||||
},
|
||||
{
|
||||
path: 'dev',
|
||||
component: CoreSettingsDevPage,
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@ -32,6 +37,7 @@ const routes: Routes = [
|
|||
],
|
||||
declarations: [
|
||||
CoreSettingsDeviceInfoPage,
|
||||
CoreSettingsDevPage,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
|
|
|
@ -23,6 +23,9 @@ import { CoreSites } from '@services/sites';
|
|||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications';
|
||||
import { CoreConfig } from '@services/config';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
|
||||
/**
|
||||
* Device Info to be shown and copied to clipboard.
|
||||
|
@ -69,6 +72,10 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
|
|||
deviceOsTranslated?: string;
|
||||
currentLangName?: string;
|
||||
fsClickable = false;
|
||||
showDevOptions = false;
|
||||
protected devOptionsClickCounter = 0;
|
||||
protected devOptionsForced = false;
|
||||
protected devOptionsClickTimeout?: number;
|
||||
|
||||
protected onlineObserver?: Subscription;
|
||||
|
||||
|
@ -171,7 +178,7 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
|
|||
this.onlineObserver = Network.onChange().subscribe(() => {
|
||||
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||
NgZone.run(() => {
|
||||
this.deviceInfo!.networkStatus = appProvider.isOnline() ? 'online' : 'offline';
|
||||
this.deviceInfo.networkStatus = appProvider.isOnline() ? 'online' : 'offline';
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -193,6 +200,10 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
|
|||
this.deviceInfo.fileSystemRoot = basepath;
|
||||
this.fsClickable = fileProvider.usesHTMLAPI();
|
||||
}
|
||||
|
||||
const showDevOptionsOnConfig = await CoreConfig.get('showDevOptions', 0);
|
||||
this.devOptionsForced = CoreConstants.BUILD.isDevelopment || CoreConstants.BUILD.isTesting;
|
||||
this.showDevOptions = this.devOptionsForced || showDevOptionsOnConfig == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -221,4 +232,44 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
|
|||
this.onlineObserver && this.onlineObserver.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* 5 clicks will enable dev options.
|
||||
*/
|
||||
async enableDevOptions(): Promise<void> {
|
||||
if (this.devOptionsForced) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.devOptionsClickTimeout);
|
||||
this.devOptionsClickCounter++;
|
||||
|
||||
if (this.devOptionsClickCounter == 5) {
|
||||
if (!this.showDevOptions) {
|
||||
this.showDevOptions = true;
|
||||
await CoreConfig.set('showDevOptions', 1);
|
||||
|
||||
CoreDomUtils.showToast('core.settings.youradev', true);
|
||||
} else {
|
||||
this.showDevOptions = false;
|
||||
await CoreConfig.delete('showDevOptions');
|
||||
}
|
||||
|
||||
this.devOptionsClickCounter = 0;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.devOptionsClickTimeout = window.setTimeout(() => {
|
||||
this.devOptionsClickTimeout = undefined;
|
||||
this.devOptionsClickCounter = 0;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to dev options.
|
||||
*/
|
||||
gotoDevOptions(): void {
|
||||
CoreNavigator.navigate('dev');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -314,6 +314,15 @@ export class CoreSitePluginsProvider {
|
|||
return this.sitePlugins[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current site plugin list.
|
||||
*
|
||||
* @return Plugin list ws info.
|
||||
*/
|
||||
getCurrentSitePluginList(): CoreSitePluginsWSPlugin[] {
|
||||
return CoreUtils.objectToArray(this.sitePlugins).map((plugin) => plugin.plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all WS call to a certain method.
|
||||
*
|
||||
|
|
|
@ -330,9 +330,9 @@ export class CoreStylesService {
|
|||
(<any> element).disabled = !!disable; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
if (disable) {
|
||||
element.setAttribute('disabled', 'true');
|
||||
element.setAttribute('media', 'disabled');
|
||||
} else {
|
||||
element.removeAttribute('disabled');
|
||||
element.removeAttribute('media');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue