commit
						51c1e423fd
					
				| @ -40,18 +40,18 @@ export class CoreDelegate { | ||||
|     /** | ||||
|      * Default handler | ||||
|      */ | ||||
|     protected defaultHandler: CoreDelegateHandler; | ||||
|     protected defaultHandler?: CoreDelegateHandler; | ||||
| 
 | ||||
|     /** | ||||
|      * Time when last updateHandler functions started. | ||||
|      */ | ||||
|     protected lastUpdateHandlersStart: number; | ||||
|     protected lastUpdateHandlersStart = 0; | ||||
| 
 | ||||
|     /** | ||||
|      * Feature prefix to check is feature is enabled or disabled in site. | ||||
|      * This check is only made if not false. Override on the subclass or override isFeatureDisabled function. | ||||
|      */ | ||||
|     protected featurePrefix: string; | ||||
|     protected featurePrefix?: string; | ||||
| 
 | ||||
|     /** | ||||
|      * Name of the property to be used to index the handlers. By default, the handler's name will be used. | ||||
| @ -78,7 +78,7 @@ export class CoreDelegate { | ||||
|     /** | ||||
|      * Function to resolve the handlers init promise. | ||||
|      */ | ||||
|     protected handlersInitResolve: () => void; | ||||
|     protected handlersInitResolve!: () => void; | ||||
| 
 | ||||
|     /** | ||||
|      * Constructor of the Delegate. | ||||
| @ -110,7 +110,7 @@ export class CoreDelegate { | ||||
|      * @param params Parameters to pass to the function. | ||||
|      * @return Function returned value or default value. | ||||
|      */ | ||||
|     protected executeFunctionOnEnabled<T = unknown>(handlerName: string, fnName: string, params?: unknown[]): T { | ||||
|     protected executeFunctionOnEnabled<T = unknown>(handlerName: string, fnName: string, params?: unknown[]): T | undefined { | ||||
|         return this.execute<T>(this.enabledHandlers[handlerName], fnName, params); | ||||
|     } | ||||
| 
 | ||||
| @ -123,7 +123,7 @@ export class CoreDelegate { | ||||
|      * @param params Parameters to pass to the function. | ||||
|      * @return Function returned value or default value. | ||||
|      */ | ||||
|     protected executeFunction<T = unknown>(handlerName: string, fnName: string, params?: unknown[]): T { | ||||
|     protected executeFunction<T = unknown>(handlerName: string, fnName: string, params?: unknown[]): T | undefined { | ||||
|         return this.execute(this.handlers[handlerName], fnName, params); | ||||
|     } | ||||
| 
 | ||||
| @ -136,7 +136,7 @@ export class CoreDelegate { | ||||
|      * @param params Parameters to pass to the function. | ||||
|      * @return Function returned value or default value. | ||||
|      */ | ||||
|     private execute<T = unknown>(handler: CoreDelegateHandler, fnName: string, params?: unknown[]): T { | ||||
|     private execute<T = unknown>(handler: CoreDelegateHandler, fnName: string, params?: unknown[]): T | undefined { | ||||
|         if (handler && handler[fnName]) { | ||||
|             return handler[fnName].apply(handler, params); | ||||
|         } else if (this.defaultHandler && this.defaultHandler[fnName]) { | ||||
| @ -252,7 +252,7 @@ export class CoreDelegate { | ||||
|             this.updatePromises[siteId] = {}; | ||||
|         } | ||||
| 
 | ||||
|         if (!CoreSites.instance.isLoggedIn() || this.isFeatureDisabled(handler, currentSite)) { | ||||
|         if (!CoreSites.instance.isLoggedIn() || this.isFeatureDisabled(handler, currentSite!)) { | ||||
|             promise = Promise.resolve(false); | ||||
|         } else { | ||||
|             promise = Promise.resolve(handler.isEnabled()).catch(() => false); | ||||
| @ -270,6 +270,8 @@ export class CoreDelegate { | ||||
|                     delete this.enabledHandlers[key]; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             return; | ||||
|         }).finally(() => { | ||||
|             // Update finished, delete the promise.
 | ||||
|             delete this.updatePromises[siteId][handler.name]; | ||||
| @ -295,7 +297,7 @@ export class CoreDelegate { | ||||
|      * @return Resolved when done. | ||||
|      */ | ||||
|     protected async updateHandlers(): Promise<void> { | ||||
|         const promises = []; | ||||
|         const promises: Promise<void>[] = []; | ||||
|         const now = Date.now(); | ||||
| 
 | ||||
|         this.logger.debug('Updating handlers for current site.'); | ||||
|  | ||||
| @ -28,7 +28,7 @@ export class CoreAjaxWSError extends CoreError { | ||||
|     backtrace?: string; // Backtrace. Only if debug mode is enabled.
 | ||||
|     available?: number; // Whether the AJAX call is available. 0 if unknown, 1 if available, -1 if not available.
 | ||||
| 
 | ||||
|     // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     constructor(error: any, available?: number) { | ||||
|         super(error.message); | ||||
| 
 | ||||
|  | ||||
| @ -27,6 +27,7 @@ export class CoreWSError extends CoreError { | ||||
|     debuginfo?: string; // Debug info. Only if debug mode is enabled.
 | ||||
|     backtrace?: string; // Backtrace. Only if debug mode is enabled.
 | ||||
| 
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     constructor(error: any) { | ||||
|         super(error.message); | ||||
| 
 | ||||
|  | ||||
| @ -30,7 +30,7 @@ export class CoreInterceptor implements HttpInterceptor { | ||||
|      * @param addNull Add null values to the serialized as empty parameters. | ||||
|      * @return Serialization of the object. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     static serialize(obj: any, addNull?: boolean): string { | ||||
|         let query = ''; | ||||
| 
 | ||||
| @ -61,7 +61,7 @@ export class CoreInterceptor implements HttpInterceptor { | ||||
|         return query.length ? query.substr(0, query.length - 1) : query; | ||||
|     } | ||||
| 
 | ||||
|     // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     intercept(req: HttpRequest<any>, next: HttpHandler): Observable<any> { | ||||
|         // Add the header and serialize the body if needed.
 | ||||
|         const newReq = req.clone({ | ||||
|  | ||||
| @ -25,7 +25,7 @@ export class CoreIonLoadingElement { | ||||
| 
 | ||||
|     constructor(public loading: HTMLIonLoadingElement) { } | ||||
| 
 | ||||
|     // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     async dismiss(data?: any, role?: string): Promise<boolean> { | ||||
|         if (!this.isPresented || this.isDismissed) { | ||||
|             this.isDismissed = true; | ||||
|  | ||||
| @ -91,7 +91,7 @@ export class CoreQueueRunner { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const item = this.orderedQueue.shift(); | ||||
|         const item = this.orderedQueue.shift()!; | ||||
|         this.numberRunning++; | ||||
| 
 | ||||
|         try { | ||||
|  | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -472,7 +472,7 @@ export class SQLiteDB { | ||||
|      * @return List of params. | ||||
|      */ | ||||
|     protected formatDataToSQLParams(data: SQLiteDBRecordValues): SQLiteDBRecordValue[] { | ||||
|         return Object.keys(data).map((key) => data[key]); | ||||
|         return Object.keys(data).map((key) => data[key]!); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -1087,7 +1087,7 @@ export class SQLiteDB { | ||||
| } | ||||
| 
 | ||||
| export type SQLiteDBRecordValues = { | ||||
|     [key in string ]: SQLiteDBRecordValue; | ||||
|     [key in string ]: SQLiteDBRecordValue | undefined; | ||||
| }; | ||||
| 
 | ||||
| export type SQLiteDBQueryParams = { | ||||
|  | ||||
| @ -13,15 +13,33 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { CommonModule } from '@angular/common'; | ||||
| import { IonicModule } from '@ionic/angular'; | ||||
| import { TranslateModule } from '@ngx-translate/core'; | ||||
| 
 | ||||
| import { CoreIconComponent } from './icon/icon'; | ||||
| import { CoreLoadingComponent } from './loading/loading'; | ||||
| import { CoreShowPasswordComponent } from './show-password/show-password'; | ||||
| import { CoreDirectivesModule } from '@app/directives/directives.module'; | ||||
| import { CorePipesModule } from '@app/pipes/pipes.module'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
|         CoreIconComponent, | ||||
|         CoreLoadingComponent, | ||||
|         CoreShowPasswordComponent, | ||||
|     ], | ||||
|     imports: [ | ||||
|         CommonModule, | ||||
|         IonicModule.forRoot(), | ||||
|         TranslateModule.forChild(), | ||||
|         CoreDirectivesModule, | ||||
|         CorePipesModule, | ||||
|     ], | ||||
|     imports: [], | ||||
|     exports: [ | ||||
|         CoreIconComponent, | ||||
|         CoreLoadingComponent, | ||||
|         CoreShowPasswordComponent, | ||||
|     ], | ||||
| }) | ||||
| export class CoreComponentsModule {} | ||||
|  | ||||
| @ -38,7 +38,7 @@ export class CoreIconComponent implements OnChanges, OnDestroy { | ||||
|     @Input() ios?: string; | ||||
| 
 | ||||
|     // FontAwesome params.
 | ||||
|     @Input('fixed-width') fixedWidth: boolean; | ||||
|     @Input('fixed-width') fixedWidth?: boolean; // eslint-disable-line @angular-eslint/no-input-rename
 | ||||
| 
 | ||||
|     @Input() label?: string; | ||||
|     @Input() flipRtl?: boolean; // Whether to flip the icon in RTL. Defaults to false.
 | ||||
| @ -48,7 +48,7 @@ export class CoreIconComponent implements OnChanges, OnDestroy { | ||||
| 
 | ||||
|     constructor(el: ElementRef) { | ||||
|         this.element = el.nativeElement; | ||||
|         this.newElement = this.element | ||||
|         this.newElement = this.element; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
							
								
								
									
										10
									
								
								src/app/components/loading/core-loading.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/components/loading/core-loading.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| <div class="core-loading-container" *ngIf="!hideUntil" role="status"> <!-- @todo [@coreShowHideAnimation]  --> | ||||
|     <span class="core-loading-spinner"> | ||||
|         <ion-spinner></ion-spinner> | ||||
|         <p class="core-loading-message" *ngIf="message" role="status">{{message}}</p> | ||||
|     </span> | ||||
| </div> | ||||
| <div #content class="core-loading-content" [id]="uniqueId" [attr.aria-busy]="hideUntil"> | ||||
|     <ng-content *ngIf="hideUntil"> | ||||
|     </ng-content> <!-- @todo [@coreShowHideAnimation]  --> | ||||
| </div> | ||||
							
								
								
									
										67
									
								
								src/app/components/loading/loading.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/app/components/loading/loading.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,67 @@ | ||||
| ion-app.app-root { | ||||
|     core-loading { | ||||
|         // @todo @include core-transition(height, 200ms); | ||||
| 
 | ||||
|         .core-loading-container { | ||||
|             width: 100%; | ||||
|             text-align: center; | ||||
|             padding-top: 10px; | ||||
|             clear: both; | ||||
|             /* @todo @include darkmode() { | ||||
|                 color: $core-dark-text-color; | ||||
|             } */ | ||||
|         } | ||||
| 
 | ||||
|         .core-loading-content { | ||||
|             display: inline; | ||||
|             padding-bottom: 1px; /* This makes height be real */ | ||||
|         } | ||||
| 
 | ||||
|         &.core-loading-noheight .core-loading-content { | ||||
|             height: auto; | ||||
|         } | ||||
| 
 | ||||
|         &.safe-area-page { | ||||
|             padding-left: 0 !important; | ||||
|             padding-right: 0 !important; | ||||
| 
 | ||||
|             > .core-loading-content > *:not[padding], | ||||
|             > .core-loading-content-loading > *:not[padding] { | ||||
|                 // @todo @include safe-area-padding-horizontal(0px, 0px); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     .scroll-content > core-loading, | ||||
|     ion-content > .scroll-content > core-loading, | ||||
|     core-tab core-loading, | ||||
|     .core-loading-center { | ||||
|         position: static !important; | ||||
|     } | ||||
| 
 | ||||
|     .scroll-content > core-loading, | ||||
|     ion-content > .scroll-content > core-loading, | ||||
|     core-tab core-loading, | ||||
|     .core-loading-center, | ||||
|     core-loading.core-loading-loaded { | ||||
|         position: relative; | ||||
| 
 | ||||
|         > .core-loading-container { | ||||
|             position: absolute; | ||||
|             // @todo @include position(0, 0, 0, 0); | ||||
|             display: table; | ||||
|             height: 100%; | ||||
|             width: 100%; | ||||
|             z-index: 1; | ||||
|             margin: 0; | ||||
|             padding: 0; | ||||
|             clear: both; | ||||
| 
 | ||||
|             .core-loading-spinner { | ||||
|                 display: table-cell; | ||||
|                 text-align: center; | ||||
|                 vertical-align: middle; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										120
									
								
								src/app/components/loading/loading.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/app/components/loading/loading.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,120 @@ | ||||
| // (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, Input, OnInit, OnChanges, SimpleChange, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; | ||||
| 
 | ||||
| import { CoreEventLoadingChangedData, CoreEvents, CoreEventsProvider } from '@services/events'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { Translate } from '@singletons/core.singletons'; | ||||
| 
 | ||||
| /** | ||||
|  * Component to show a loading spinner and message while data is being loaded. | ||||
|  * | ||||
|  * It will show a spinner with a message and hide all the content until 'hideUntil' variable is set to a truthy value (!!hideUntil). | ||||
|  * If 'message' isn't set, default message "Loading" is shown. | ||||
|  * 'message' attribute accepts hardcoded strings, variables, filters, etc. E.g. [message]="'core.loading' | translate". | ||||
|  * | ||||
|  * Usage: | ||||
|  * <core-loading [message]="loadingMessage" [hideUntil]="dataLoaded"> | ||||
|  *     <!-- CONTENT TO HIDE UNTIL LOADED --> | ||||
|  * </core-loading> | ||||
|  * | ||||
|  * IMPORTANT: Due to how ng-content works in Angular, the content of core-loading will be executed as soon as your view | ||||
|  * is loaded, even if the content hidden. So if you have the following code: | ||||
|  * <core-loading [hideUntil]="dataLoaded"><my-component></my-component></core-loading> | ||||
|  * | ||||
|  * The component "my-component" will be initialized immediately, even if dataLoaded is false, but it will be hidden. If you want | ||||
|  * your component to be initialized only if dataLoaded is true, then you should use ngIf: | ||||
|  * <core-loading [hideUntil]="dataLoaded"><my-component *ngIf="dataLoaded"></my-component></core-loading> | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'core-loading', | ||||
|     templateUrl: 'core-loading.html', | ||||
|     styleUrls: ['loading.scss'], | ||||
|     // @todo animations: [coreShowHideAnimation],
 | ||||
| }) | ||||
| export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit { | ||||
| 
 | ||||
|     @Input() hideUntil: unknown; // Determine when should the contents be shown.
 | ||||
|     @Input() message?: string; // Message to show while loading.
 | ||||
|     @ViewChild('content') content?: ElementRef; | ||||
| 
 | ||||
|     protected uniqueId!: string; | ||||
|     protected element: HTMLElement; // Current element.
 | ||||
| 
 | ||||
|     constructor(element: ElementRef) { | ||||
|         this.element = element.nativeElement; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         // Calculate the unique ID.
 | ||||
|         this.uniqueId = 'core-loading-content-' + CoreUtils.instance.getUniqueId('CoreLoadingComponent'); | ||||
| 
 | ||||
|         if (!this.message) { | ||||
|             // Default loading message.
 | ||||
|             this.message = Translate.instance.instant('core.loading'); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * View has been initialized. | ||||
|      */ | ||||
|     ngAfterViewInit(): void { | ||||
|         // Add class if loaded on init.
 | ||||
|         if (this.hideUntil) { | ||||
|             this.element.classList.add('core-loading-loaded'); | ||||
|             this.content?.nativeElement.classList.add('core-loading-content'); | ||||
|         } else { | ||||
|             this.content?.nativeElement.classList.remove('core-loading-content'); | ||||
|             this.content?.nativeElement.classList.add('core-loading-content-loading'); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component input changed. | ||||
|      * | ||||
|      * @param changes Changes. | ||||
|      */ | ||||
|     ngOnChanges(changes: { [name: string]: SimpleChange }): void { | ||||
|         if (changes.hideUntil) { | ||||
|             if (this.hideUntil) { | ||||
|                 setTimeout(() => { | ||||
|                     // Content is loaded so, center the spinner on the content itself.
 | ||||
|                     this.element.classList.add('core-loading-loaded'); | ||||
|                     setTimeout(() => { | ||||
|                         // Change CSS to force calculate height.
 | ||||
|                         this.content?.nativeElement.classList.add('core-loading-content'); | ||||
|                         this.content?.nativeElement.classList.remove('core-loading-content-loading'); | ||||
|                     }, 500); | ||||
|                 }); | ||||
|             } else { | ||||
|                 this.element.classList.remove('core-loading-loaded'); | ||||
|                 this.content?.nativeElement.classList.remove('core-loading-content'); | ||||
|                 this.content?.nativeElement.classList.add('core-loading-content-loading'); | ||||
|             } | ||||
| 
 | ||||
|             // Trigger the event after a timeout since the elements inside ngIf haven't been added to DOM yet.
 | ||||
|             setTimeout(() => { | ||||
|                 CoreEvents.instance.trigger(CoreEventsProvider.CORE_LOADING_CHANGED, <CoreEventLoadingChangedData> { | ||||
|                     loaded: !!this.hideUntil, | ||||
|                     uniqueId: this.uniqueId, | ||||
|                 }); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										4
									
								
								src/app/components/show-password/core-show-password.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/app/components/show-password/core-show-password.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| <ng-content></ng-content> | ||||
| <ion-button icon-only clear [attr.aria-label]="label | translate" [core-suppress-events] (onClick)="toggle($event)"> | ||||
|     <core-icon [name]="iconName"></core-icon> | ||||
| </ion-button> | ||||
							
								
								
									
										38
									
								
								src/app/components/show-password/show-password.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/app/components/show-password/show-password.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | ||||
| ion-app.app-root core-show-password { | ||||
|     padding: 0px; | ||||
|     width: 100%; | ||||
|     position: relative; | ||||
| 
 | ||||
|     ion-input input.text-input { | ||||
|         // @todo @include padding(null, 47px, null, null); | ||||
|     } | ||||
| 
 | ||||
|     .button[icon-only] { | ||||
|         background: transparent; | ||||
|         // @todo padding: 0 ($content-padding / 2); | ||||
|         position: absolute; | ||||
|         // @todo @include position(null, 0, $content-padding / 2, null); | ||||
|         margin-top: 0; | ||||
|         margin-bottom: 0; | ||||
|     } | ||||
| 
 | ||||
|     .core-ioninput-password { | ||||
|         padding-top: 0; | ||||
|         padding-bottom: 0; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| ion-app.app-root.md { | ||||
|     .item-label-stacked core-show-password .button[icon-only] { | ||||
|         bottom: 0; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| ion-app.app-root.ios { | ||||
|     .item-label-stacked core-show-password .button[icon-only] { | ||||
|         bottom: -5px; | ||||
|     } | ||||
|     core-show-password .button[icon-only] { | ||||
|         bottom: 0; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										136
									
								
								src/app/components/show-password/show-password.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/app/components/show-password/show-password.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,136 @@ | ||||
| // (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, AfterViewInit, Input, ElementRef, ContentChild } from '@angular/core'; | ||||
| import { IonInput } from '@ionic/angular'; | ||||
| 
 | ||||
| import { CoreApp } from '@services/app'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| 
 | ||||
| /** | ||||
|  * Component to allow showing and hiding a password. The affected input MUST have a name to identify it. | ||||
|  * | ||||
|  * @description | ||||
|  * This directive needs to surround the input with the password. | ||||
|  * | ||||
|  * You need to supply the name of the input. | ||||
|  * | ||||
|  * Example: | ||||
|  * | ||||
|  * <core-show-password item-content [name]="'password'"> | ||||
|  *     <ion-input type="password" name="password"></ion-input> | ||||
|  * </core-show-password> | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'core-show-password', | ||||
|     templateUrl: 'core-show-password.html', | ||||
|     styleUrls: ['show-password.scss'], | ||||
| }) | ||||
| export class CoreShowPasswordComponent implements OnInit, AfterViewInit { | ||||
| 
 | ||||
|     @Input() name?: string; // Name of the input affected.
 | ||||
|     @Input() initialShown?: boolean | string; // Whether the password should be shown at start.
 | ||||
|     @ContentChild(IonInput) ionInput?: IonInput; | ||||
| 
 | ||||
|     shown!: boolean; // Whether the password is shown.
 | ||||
|     label?: string; // Label for the button to show/hide.
 | ||||
|     iconName?: string; // Name of the icon of the button to show/hide.
 | ||||
|     selector = ''; // Selector to identify the input.
 | ||||
| 
 | ||||
|     protected input?: HTMLInputElement | null; // Input affected.
 | ||||
|     protected element: HTMLElement; // Current element.
 | ||||
| 
 | ||||
|     constructor(element: ElementRef) { | ||||
|         this.element = element.nativeElement; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         this.shown = CoreUtils.instance.isTrueOrOne(this.initialShown); | ||||
|         this.selector = 'input[name="' + this.name + '"]'; | ||||
|         this.setData(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * View has been initialized. | ||||
|      */ | ||||
|     ngAfterViewInit(): void { | ||||
|         this.searchInput(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Search the input to show/hide. | ||||
|      */ | ||||
|     protected async searchInput(): Promise<void> { | ||||
|         if (this.ionInput) { | ||||
|             // It's an ion-input, use it to get the native element.
 | ||||
|             this.input = await this.ionInput.getInputElement(); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Search the input.
 | ||||
|         this.input = <HTMLInputElement> this.element.querySelector(this.selector); | ||||
| 
 | ||||
|         if (this.input) { | ||||
|             // Input found. Set the right type.
 | ||||
|             this.input.type = this.shown ? 'text' : 'password'; | ||||
| 
 | ||||
|             // By default, don't autocapitalize and autocorrect.
 | ||||
|             if (!this.input.getAttribute('autocorrect')) { | ||||
|                 this.input.setAttribute('autocorrect', 'off'); | ||||
|             } | ||||
|             if (!this.input.getAttribute('autocapitalize')) { | ||||
|                 this.input.setAttribute('autocapitalize', 'none'); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set label, icon name and input type. | ||||
|      */ | ||||
|     protected setData(): void { | ||||
|         this.label = this.shown ? 'core.hide' : 'core.show'; | ||||
|         this.iconName = this.shown ? 'eye-off' : 'eye'; | ||||
|         if (this.input) { | ||||
|             this.input.type = this.shown ? 'text' : 'password'; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Toggle show/hide password. | ||||
|      * | ||||
|      * @param event The mouse event. | ||||
|      */ | ||||
|     toggle(event: Event): void { | ||||
|         event.preventDefault(); | ||||
|         event.stopPropagation(); | ||||
| 
 | ||||
|         const isFocused = document.activeElement === this.input; | ||||
| 
 | ||||
|         this.shown = !this.shown; | ||||
|         this.setData(); | ||||
| 
 | ||||
|         if (isFocused && CoreApp.instance.isAndroid()) { | ||||
|             // In Android, the keyboard is closed when the input type changes. Focus it again.
 | ||||
|             setTimeout(() => { | ||||
|                 CoreDomUtils.instance.focusElement(this.input!); | ||||
|             }, 400); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -39,7 +39,7 @@ export class CoreConstants { | ||||
|     static readonly DOWNLOAD_THRESHOLD = 10485760; // 10MB.
 | ||||
|     static readonly MINIMUM_FREE_SPACE = 10485760; // 10MB.
 | ||||
|     static readonly IOS_FREE_SPACE_THRESHOLD = 524288000; // 500MB.
 | ||||
|     static readonly DONT_SHOW_ERROR = 'CoreDontShowError'; | ||||
|     static readonly DONT_SHOW_ERROR = 'CoreDontShowError'; // @deprecated since 3.9.5. Use CoreSilentError instead.
 | ||||
|     static readonly NO_SITE_ID = 'NoSite'; | ||||
| 
 | ||||
|     // Settings constants.
 | ||||
|  | ||||
| @ -35,6 +35,7 @@ export class SQLiteDBMock extends SQLiteDB { | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     close(): Promise<any> { | ||||
|         // WebSQL databases aren't closed.
 | ||||
|         return Promise.resolve(); | ||||
| @ -45,6 +46,7 @@ export class SQLiteDBMock extends SQLiteDB { | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     async emptyDatabase(): Promise<any> { | ||||
|         await this.ready(); | ||||
| 
 | ||||
| @ -89,6 +91,7 @@ export class SQLiteDBMock extends SQLiteDB { | ||||
|      * @param params Query parameters. | ||||
|      * @return Promise resolved with the result. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     async execute(sql: string, params?: any[]): Promise<any> { | ||||
|         await this.ready(); | ||||
| 
 | ||||
| @ -115,6 +118,7 @@ export class SQLiteDBMock extends SQLiteDB { | ||||
|      * @param sqlStatements SQL statements to execute. | ||||
|      * @return Promise resolved with the result. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     async executeBatch(sqlStatements: any[]): Promise<any> { | ||||
|         await this.ready(); | ||||
| 
 | ||||
| @ -148,6 +152,7 @@ export class SQLiteDBMock extends SQLiteDB { | ||||
|                     })); | ||||
|                 }); | ||||
| 
 | ||||
|                 // eslint-disable-next-line promise/catch-or-return
 | ||||
|                 Promise.all(promises).then(resolve, reject); | ||||
|             }); | ||||
|         }); | ||||
| @ -158,6 +163,7 @@ export class SQLiteDBMock extends SQLiteDB { | ||||
|      */ | ||||
|     init(): void { | ||||
|         // This DB is for desktop apps, so use a big size to be sure it isn't filled.
 | ||||
|         // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|         this.db = (<any> window).openDatabase(this.name, '1.0', this.name, 500 * 1024 * 1024); | ||||
|         this.promise = Promise.resolve(); | ||||
|     } | ||||
|  | ||||
| @ -15,8 +15,10 @@ | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { RouterModule, Routes } from '@angular/router'; | ||||
| 
 | ||||
| import { CoreLoginCredentialsPage } from './pages/credentials/credentials.page'; | ||||
| import { CoreLoginInitPage } from './pages/init/init.page'; | ||||
| import { CoreLoginSitePage } from './pages/site/site.page'; | ||||
| import { CoreLoginSitesPage } from './pages/sites/sites.page'; | ||||
| 
 | ||||
| const routes: Routes = [ | ||||
|     { | ||||
| @ -27,6 +29,14 @@ const routes: Routes = [ | ||||
|         path: 'site', | ||||
|         component: CoreLoginSitePage, | ||||
|     }, | ||||
|     { | ||||
|         path: 'credentials', | ||||
|         component: CoreLoginCredentialsPage, | ||||
|     }, | ||||
|     { | ||||
|         path: 'sites', | ||||
|         component: CoreLoginSitesPage, | ||||
|     }, | ||||
| ]; | ||||
| 
 | ||||
| @NgModule({ | ||||
|  | ||||
| @ -14,13 +14,20 @@ | ||||
| 
 | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { CommonModule } from '@angular/common'; | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | ||||
| 
 | ||||
| import { IonicModule } from '@ionic/angular'; | ||||
| import { TranslateModule } from '@ngx-translate/core'; | ||||
| 
 | ||||
| import { CoreComponentsModule } from '@/app/components/components.module'; | ||||
| import { CoreDirectivesModule } from '@/app/directives/directives.module'; | ||||
| 
 | ||||
| import { CoreLoginRoutingModule } from './login-routing.module'; | ||||
| import { CoreLoginCredentialsPage } from './pages/credentials/credentials.page'; | ||||
| import { CoreLoginInitPage } from './pages/init/init.page'; | ||||
| import { CoreLoginSitePage } from './pages/site/site.page'; | ||||
| import { CoreLoginSitesPage } from './pages/sites/sites.page'; | ||||
| import { CoreLoginHelperProvider } from './services/helper'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     imports: [ | ||||
| @ -28,10 +35,19 @@ import { CoreLoginSitePage } from './pages/site/site.page'; | ||||
|         IonicModule, | ||||
|         CoreLoginRoutingModule, | ||||
|         TranslateModule.forChild(), | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         CoreComponentsModule, | ||||
|         CoreDirectivesModule, | ||||
|     ], | ||||
|     declarations: [ | ||||
|         CoreLoginCredentialsPage, | ||||
|         CoreLoginInitPage, | ||||
|         CoreLoginSitePage, | ||||
|         CoreLoginSitesPage, | ||||
|     ], | ||||
|     providers: [ | ||||
|         CoreLoginHelperProvider, | ||||
|     ], | ||||
| }) | ||||
| export class CoreLoginModule {} | ||||
|  | ||||
							
								
								
									
										75
									
								
								src/app/core/login/pages/credentials/credentials.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/app/core/login/pages/credentials/credentials.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | ||||
| <ion-header> | ||||
|     <ion-toolbar> | ||||
|         <ion-buttons slot="start"> | ||||
|             <ion-back-button></ion-back-button> | ||||
|         </ion-buttons> | ||||
| 
 | ||||
|         <ion-title>{{ 'core.login.login' | translate }}</ion-title> | ||||
| 
 | ||||
|         <ion-buttons slot="end"> | ||||
|             <!-- @todo: Settings button. --> | ||||
|         </ion-buttons> | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content padding> | ||||
|     <core-loading [hideUntil]="pageLoaded"> | ||||
|         <div text-wrap text-center margin-bottom> | ||||
|             <div class="core-login-site-logo"> | ||||
|                 <!-- Show site logo or a default image. --> | ||||
|                 <img *ngIf="logoUrl" [src]="logoUrl" role="presentation" onError="this.src='assets/img/login_logo.png'"> | ||||
|                 <img *ngIf="!logoUrl" src="assets/img/login_logo.png" role="presentation"> | ||||
|             </div> | ||||
| 
 | ||||
|             <h3 *ngIf="siteName" padding class="core-sitename"><core-format-text [text]="siteName" [filter]="false"></core-format-text></h3> | ||||
|             <p class="core-siteurl">{{siteUrl}}</p> | ||||
|         </div> | ||||
| 
 | ||||
|         <form ion-list [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #credentialsForm> | ||||
|             <ion-item *ngIf="siteChecked && !isBrowserSSO"> | ||||
|                 <ion-input type="text" name="username" placeholder="{{ 'core.login.username' | translate }}" formControlName="username" autocapitalize="none" autocorrect="off"></ion-input> | ||||
|             </ion-item> | ||||
|             <ion-item *ngIf="siteChecked && !isBrowserSSO" margin-bottom> | ||||
|                 <core-show-password item-content [name]="'password'"> | ||||
|                     <ion-input class="core-ioninput-password" name="password" type="password" placeholder="{{ 'core.login.password' | translate }}" formControlName="password" core-show-password [clearOnEdit]="false"></ion-input> | ||||
|                 </core-show-password> | ||||
|             </ion-item> | ||||
|             <div padding> | ||||
|                 <ion-button block type="submit" [disabled]="siteChecked && !isBrowserSSO && !credForm.valid" class="core-login-login-button">{{ 'core.login.loginbutton' | translate }}</ion-button> | ||||
|                 <input type="submit" className="core-submit-enter" /> <!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 --> | ||||
|             </div> | ||||
| 
 | ||||
|             <ng-container *ngIf="showScanQR"> | ||||
|                 <div class="core-login-site-qrcode-separator">{{ 'core.login.or' | translate }}</div> | ||||
|                 <ion-item class="core-login-site-qrcode" no-lines> | ||||
|                     <ion-button block color="light" margin-top icon-start text-wrap (click)="showInstructionsAndScanQR()"> | ||||
|                         <core-icon name="fa-qrcode" aria-hidden="true"></core-icon> | ||||
|                         {{ 'core.scanqr' | translate }} | ||||
|                     </ion-button> | ||||
|                 </ion-item> | ||||
|             </ng-container> | ||||
|         </form> | ||||
| 
 | ||||
|         <!-- Forgotten password button. --> | ||||
|         <ion-list no-lines *ngIf="showForgottenPassword" class="core-login-forgotten-password"> | ||||
|             <ion-item text-center text-wrap (click)="forgottenPassword()" detail-none> | ||||
|                 {{ 'core.login.forgotten' | translate }} | ||||
|             </ion-item> | ||||
|         </ion-list> | ||||
| 
 | ||||
|         <ion-list *ngIf="identityProviders && identityProviders.length" padding-top class="core-login-identity-providers"> | ||||
|             <ion-item text-wrap no-lines><h3 class="item-heading">{{ 'core.login.potentialidps' | translate }}</h3></ion-item> | ||||
|             <ion-item *ngFor="let provider of identityProviders" text-wrap class="core-oauth-icon" (click)="oauthClicked(provider)" title="{{provider.name}}"> | ||||
|                 <img [src]="provider.iconurl" alt="" width="32" height="32" item-start> | ||||
|                 {{provider.name}} | ||||
|             </ion-item> | ||||
|         </ion-list> | ||||
| 
 | ||||
|         <ion-list *ngIf="canSignup" padding-top class="core-login-sign-up"> | ||||
|             <ion-item text-wrap no-lines><h3 class="item-heading">{{ 'core.login.firsttime' | translate }}</h3></ion-item> | ||||
|             <ion-item no-lines text-wrap *ngIf="authInstructions"> | ||||
|                 <p><core-format-text [text]="authInstructions" [filter]="false"></core-format-text></p> | ||||
|             </ion-item> | ||||
|             <ion-button block color="light" (onClick)="signup()">{{ 'core.login.startsignup' | translate }}</ion-button> | ||||
|         </ion-list> | ||||
|     </core-loading> | ||||
| </ion-content> | ||||
							
								
								
									
										336
									
								
								src/app/core/login/pages/credentials/credentials.page.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										336
									
								
								src/app/core/login/pages/credentials/credentials.page.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,336 @@ | ||||
| // (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, OnDestroy, ViewChild, ElementRef } from '@angular/core'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||
| import { NavController } from '@ionic/angular'; | ||||
| 
 | ||||
| import { CoreApp } from '@services/app'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/helper'; | ||||
| import CoreConfigConstants from '@app/config.json'; | ||||
| import { Translate } from '@singletons/core.singletons'; | ||||
| import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@/app/classes/site'; | ||||
| import { CoreEvents, CoreEventsProvider } from '@/app/services/events'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays a "splash screen" while the app is being initialized. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'page-core-login-credentials', | ||||
|     templateUrl: 'credentials.html', | ||||
| }) | ||||
| export class CoreLoginCredentialsPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|     @ViewChild('credentialsForm') formElement?: ElementRef; | ||||
| 
 | ||||
|     credForm!: FormGroup; | ||||
|     siteUrl!: string; | ||||
|     siteChecked = false; | ||||
|     siteName?: string; | ||||
|     logoUrl?: string; | ||||
|     authInstructions?: string; | ||||
|     canSignup?: boolean; | ||||
|     identityProviders?: CoreSiteIdentityProvider[]; | ||||
|     pageLoaded = false; | ||||
|     isBrowserSSO = false; | ||||
|     isFixedUrlSet = false; | ||||
|     showForgottenPassword = true; | ||||
|     showScanQR: boolean; | ||||
| 
 | ||||
|     protected siteConfig?: CoreSitePublicConfigResponse; | ||||
|     protected eventThrown = false; | ||||
|     protected viewLeft = false; | ||||
|     protected siteId?: string; | ||||
|     protected urlToOpen?: string; | ||||
| 
 | ||||
|     constructor( | ||||
|         protected fb: FormBuilder, | ||||
|         protected route: ActivatedRoute, | ||||
|         protected navCtrl: NavController, | ||||
|     ) { | ||||
| 
 | ||||
|         const canScanQR = CoreUtils.instance.canScanQR(); | ||||
|         if (canScanQR) { | ||||
|             if (typeof CoreConfigConstants['displayqroncredentialscreen'] == 'undefined') { | ||||
|                 this.showScanQR = CoreLoginHelper.instance.isFixedUrlSet(); | ||||
|             } else { | ||||
|                 this.showScanQR = !!CoreConfigConstants['displayqroncredentialscreen']; | ||||
|             } | ||||
|         } else { | ||||
|             this.showScanQR = false; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize the component. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         this.route.queryParams.subscribe(params => { | ||||
|             this.siteUrl = params['siteUrl']; | ||||
|             this.siteName = params['siteName'] || undefined; | ||||
|             this.logoUrl = !CoreConfigConstants.forceLoginLogo && params['logoUrl'] || undefined; | ||||
|             this.siteConfig = params['siteConfig']; | ||||
|             this.urlToOpen = params['urlToOpen']; | ||||
| 
 | ||||
|             this.credForm = this.fb.group({ | ||||
|                 username: [params['username'] || '', Validators.required], | ||||
|                 password: ['', Validators.required], | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         this.treatSiteConfig(); | ||||
|         this.isFixedUrlSet = CoreLoginHelper.instance.isFixedUrlSet(); | ||||
| 
 | ||||
|         if (this.isFixedUrlSet) { | ||||
|             // Fixed URL, we need to check if it uses browser SSO login.
 | ||||
|             this.checkSite(this.siteUrl); | ||||
|         } else { | ||||
|             this.siteChecked = true; | ||||
|             this.pageLoaded = true; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if a site uses local_mobile, requires SSO login, etc. | ||||
|      * This should be used only if a fixed URL is set, otherwise this check is already performed in CoreLoginSitePage. | ||||
|      * | ||||
|      * @param siteUrl Site URL to check. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async checkSite(siteUrl: string): Promise<void> { | ||||
|         this.pageLoaded = false; | ||||
| 
 | ||||
|         // If the site is configured with http:// protocol we force that one, otherwise we use default mode.
 | ||||
|         const protocol = siteUrl.indexOf('http://') === 0 ? 'http://' : undefined; | ||||
| 
 | ||||
|         try { | ||||
|             const result = await CoreSites.instance.checkSite(siteUrl, protocol); | ||||
| 
 | ||||
|             this.siteChecked = true; | ||||
|             this.siteUrl = result.siteUrl; | ||||
| 
 | ||||
|             this.siteConfig = result.config; | ||||
|             this.treatSiteConfig(); | ||||
| 
 | ||||
|             if (result && result.warning) { | ||||
|                 CoreDomUtils.instance.showErrorModal(result.warning, true, 4000); | ||||
|             } | ||||
| 
 | ||||
|             if (CoreLoginHelper.instance.isSSOLoginNeeded(result.code)) { | ||||
|                 // SSO. User needs to authenticate in a browser.
 | ||||
|                 this.isBrowserSSO = true; | ||||
| 
 | ||||
|                 // Check that there's no SSO authentication ongoing and the view hasn't changed.
 | ||||
|                 if (!CoreApp.instance.isSSOAuthenticationOngoing() && !this.viewLeft) { | ||||
|                     CoreLoginHelper.instance.confirmAndOpenBrowserForSSOLogin( | ||||
|                         result.siteUrl, | ||||
|                         result.code, | ||||
|                         result.service, | ||||
|                         result.config?.launchurl, | ||||
|                     ); | ||||
|                 } | ||||
|             } else { | ||||
|                 this.isBrowserSSO = false; | ||||
|             } | ||||
| 
 | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.instance.showErrorModal(error); | ||||
|         } finally { | ||||
|             this.pageLoaded = true; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Treat the site configuration (if it exists). | ||||
|      */ | ||||
|     protected treatSiteConfig(): void { | ||||
|         if (this.siteConfig) { | ||||
|             this.siteName = CoreConfigConstants.sitename ? CoreConfigConstants.sitename : this.siteConfig.sitename; | ||||
|             this.logoUrl = CoreLoginHelper.instance.getLogoUrl(this.siteConfig); | ||||
|             this.authInstructions = this.siteConfig.authinstructions || Translate.instance.instant('core.login.loginsteps'); | ||||
| 
 | ||||
|             const disabledFeatures = CoreLoginHelper.instance.getDisabledFeatures(this.siteConfig); | ||||
|             this.identityProviders = CoreLoginHelper.instance.getValidIdentityProviders(this.siteConfig, disabledFeatures); | ||||
|             this.canSignup = this.siteConfig.registerauth == 'email' && | ||||
|                     !CoreLoginHelper.instance.isEmailSignupDisabled(this.siteConfig, disabledFeatures); | ||||
|             this.showForgottenPassword = !CoreLoginHelper.instance.isForgottenPasswordDisabled(this.siteConfig, disabledFeatures); | ||||
| 
 | ||||
|             if (!this.eventThrown && !this.viewLeft) { | ||||
|                 this.eventThrown = true; | ||||
|                 CoreEvents.instance.trigger(CoreEventsProvider.LOGIN_SITE_CHECKED, { config: this.siteConfig }); | ||||
|             } | ||||
|         } else { | ||||
|             this.authInstructions = undefined; | ||||
|             this.canSignup = false; | ||||
|             this.identityProviders = []; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Tries to authenticate the user. | ||||
|      * | ||||
|      * @param e Event. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async login(e?: Event): Promise<void> { | ||||
|         if (e) { | ||||
|             e.preventDefault(); | ||||
|             e.stopPropagation(); | ||||
|         } | ||||
| 
 | ||||
|         CoreApp.instance.closeKeyboard(); | ||||
| 
 | ||||
|         // Get input data.
 | ||||
|         const siteUrl = this.siteUrl; | ||||
|         const username = this.credForm.value.username; | ||||
|         const password = this.credForm.value.password; | ||||
| 
 | ||||
|         if (!this.siteChecked || this.isBrowserSSO) { | ||||
|             // Site wasn't checked (it failed) or a previous check determined it was SSO. Let's check again.
 | ||||
|             await this.checkSite(siteUrl); | ||||
| 
 | ||||
|             if (!this.isBrowserSSO) { | ||||
|                 // Site doesn't use browser SSO, throw app's login again.
 | ||||
|                 return this.login(); | ||||
|             } | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!username) { | ||||
|             CoreDomUtils.instance.showErrorModal('core.login.usernamerequired', true); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
|         if (!password) { | ||||
|             CoreDomUtils.instance.showErrorModal('core.login.passwordrequired', true); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!CoreApp.instance.isOnline()) { | ||||
|             CoreDomUtils.instance.showErrorModal('core.networkerrormsg', true); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const modal = await CoreDomUtils.instance.showModalLoading(); | ||||
| 
 | ||||
|         // Start the authentication process.
 | ||||
|         try { | ||||
|             const data = await CoreSites.instance.getUserToken(siteUrl, username, password); | ||||
| 
 | ||||
|             const id = await CoreSites.instance.newSite(data.siteUrl, data.token, data.privateToken); | ||||
| 
 | ||||
|             // Reset fields so the data is not in the view anymore.
 | ||||
|             this.credForm.controls['username'].reset(); | ||||
|             this.credForm.controls['password'].reset(); | ||||
| 
 | ||||
|             this.siteId = id; | ||||
| 
 | ||||
|             await CoreLoginHelper.instance.goToSiteInitialPage(undefined, undefined, undefined, undefined, this.urlToOpen); | ||||
|         } catch (error) { | ||||
|             CoreLoginHelper.instance.treatUserTokenError(siteUrl, error, username, password); | ||||
| 
 | ||||
|             if (error.loggedout) { | ||||
|                 this.navCtrl.navigateRoot('/login/sites'); | ||||
|             } else if (error.errorcode == 'forcepasswordchangenotice') { | ||||
|                 // Reset password field.
 | ||||
|                 this.credForm.controls.password.reset(); | ||||
|             } | ||||
|         } finally { | ||||
|             modal.dismiss(); | ||||
| 
 | ||||
|             CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Forgotten password button clicked. | ||||
|      */ | ||||
|     forgottenPassword(): void { | ||||
|         CoreLoginHelper.instance.forgottenPasswordClicked( | ||||
|             this.navCtrl, | ||||
|             this.siteUrl, | ||||
|             this.credForm.value.username, | ||||
|             this.siteConfig, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * An OAuth button was clicked. | ||||
|      * | ||||
|      * @param provider The provider that was clicked. | ||||
|      */ | ||||
|     oauthClicked(provider: CoreSiteIdentityProvider): void { | ||||
|         if (!CoreLoginHelper.instance.openBrowserForOAuthLogin(this.siteUrl, provider, this.siteConfig?.launchurl)) { | ||||
|             CoreDomUtils.instance.showErrorModal('Invalid data.'); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Signup button was clicked. | ||||
|      */ | ||||
|     signup(): void { | ||||
|         // @todo Go to signup.
 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show instructions and scan QR code. | ||||
|      */ | ||||
|     showInstructionsAndScanQR(): void { | ||||
|         // Show some instructions first.
 | ||||
|         CoreDomUtils.instance.showAlertWithOptions({ | ||||
|             header: Translate.instance.instant('core.login.faqwhereisqrcode'), | ||||
|             message: Translate.instance.instant( | ||||
|                 'core.login.faqwhereisqrcodeanswer', | ||||
|                 { $image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML }, | ||||
|             ), | ||||
|             buttons: [ | ||||
|                 { | ||||
|                     text: Translate.instance.instant('core.cancel'), | ||||
|                     role: 'cancel', | ||||
|                 }, | ||||
|                 { | ||||
|                     text: Translate.instance.instant('core.next'), | ||||
|                     handler: (): void => { | ||||
|                         this.scanQR(); | ||||
|                     }, | ||||
|                 }, | ||||
|             ], | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Scan a QR code and put its text in the URL input. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async scanQR(): Promise<void> { | ||||
|         // @todo Scan for a QR code.
 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * View destroyed. | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.viewLeft = true; | ||||
|         CoreEvents.instance.trigger(CoreEventsProvider.LOGIN_SITE_UNCHECKED, { config: this.siteConfig }, this.siteId); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -13,7 +13,7 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { NavController } from '@ionic/angular'; | ||||
| 
 | ||||
| import { CoreApp } from '@services/app'; | ||||
| import { CoreInit } from '@services/init'; | ||||
| @ -29,49 +29,49 @@ import { SplashScreen } from '@singletons/core.singletons'; | ||||
| }) | ||||
| export class CoreLoginInitPage implements OnInit { | ||||
| 
 | ||||
|     constructor(protected router: Router) {} | ||||
|     constructor(protected navCtrl: NavController) {} | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize the component. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         // Wait for the app to be ready.
 | ||||
|         CoreInit.instance.ready().then(() => { | ||||
|             // Check if there was a pending redirect.
 | ||||
|             const redirectData = CoreApp.instance.getRedirect(); | ||||
|             if (redirectData.siteId) { | ||||
|                 // Unset redirect data.
 | ||||
|                 CoreApp.instance.storeRedirect('', '', {}); | ||||
|         await CoreInit.instance.ready(); | ||||
| 
 | ||||
|                 // Only accept the redirect if it was stored less than 20 seconds ago.
 | ||||
|                 if (Date.now() - redirectData.timemodified < 20000) { | ||||
|                     // if (redirectData.siteId != CoreConstants.NO_SITE_ID) {
 | ||||
|                     //     // The redirect is pointing to a site, load it.
 | ||||
|                     //     return this.sitesProvider.loadSite(redirectData.siteId, redirectData.page, redirectData.params)
 | ||||
|                     //             .then((loggedIn) => {
 | ||||
|         // Check if there was a pending redirect.
 | ||||
|         const redirectData = CoreApp.instance.getRedirect(); | ||||
|         if (redirectData.siteId) { | ||||
|             // Unset redirect data.
 | ||||
|             CoreApp.instance.storeRedirect('', '', {}); | ||||
| 
 | ||||
|                     //         if (loggedIn) {
 | ||||
|                     //             return this.loginHelper.goToSiteInitialPage(this.navCtrl, redirectData.page, redirectData.params,
 | ||||
|                     //                     { animate: false });
 | ||||
|                     //         }
 | ||||
|                     //     }).catch(() => {
 | ||||
|                     //         // Site doesn't exist.
 | ||||
|                     //         return this.loadPage();
 | ||||
|                     //     });
 | ||||
|                     // } else {
 | ||||
|                     //     // No site to load, open the page.
 | ||||
|                     //     return this.loginHelper.goToNoSitePage(this.navCtrl, redirectData.page, redirectData.params);
 | ||||
|                     // }
 | ||||
|                 } | ||||
|             // Only accept the redirect if it was stored less than 20 seconds ago.
 | ||||
|             if (redirectData.timemodified && Date.now() - redirectData.timemodified < 20000) { | ||||
|                 // if (redirectData.siteId != CoreConstants.NO_SITE_ID) {
 | ||||
|                 //     // The redirect is pointing to a site, load it.
 | ||||
|                 //     return this.sitesProvider.loadSite(redirectData.siteId, redirectData.page, redirectData.params)
 | ||||
|                 //             .then((loggedIn) => {
 | ||||
| 
 | ||||
|                 //         if (loggedIn) {
 | ||||
|                 //             return this.loginHelper.goToSiteInitialPage(this.navCtrl, redirectData.page, redirectData.params,
 | ||||
|                 //                     { animate: false });
 | ||||
|                 //         }
 | ||||
|                 //     }).catch(() => {
 | ||||
|                 //         // Site doesn't exist.
 | ||||
|                 //         return this.loadPage();
 | ||||
|                 //     });
 | ||||
|                 // } else {
 | ||||
|                 //     // No site to load, open the page.
 | ||||
|                 //     return this.loginHelper.goToNoSitePage(this.navCtrl, redirectData.page, redirectData.params);
 | ||||
|                 // }
 | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|             return this.loadPage(); | ||||
|         }).then(() => { | ||||
|             // If we hide the splash screen now, the init view is still seen for an instant. Wait a bit to make sure it isn't seen.
 | ||||
|             setTimeout(() => { | ||||
|                 SplashScreen.instance.hide(); | ||||
|             }, 100); | ||||
|         }); | ||||
|         await this.loadPage(); | ||||
| 
 | ||||
|         // If we hide the splash screen now, the init view is still seen for an instant. Wait a bit to make sure it isn't seen.
 | ||||
|         setTimeout(() => { | ||||
|             SplashScreen.instance.hide(); | ||||
|         }, 100); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -90,6 +90,7 @@ export class CoreLoginInitPage implements OnInit { | ||||
|         //     return this.loginHelper.goToSiteInitialPage();
 | ||||
|         // }
 | ||||
| 
 | ||||
|         await this.router.navigate(['/login/site']); | ||||
|         await this.navCtrl.navigateRoot('/login/sites'); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -1,3 +1,105 @@ | ||||
| <ion-content> | ||||
|     {{ 'core.login.yourenteredsite' | translate }} | ||||
| <ion-header> | ||||
|     <ion-toolbar> | ||||
|         <ion-buttons slot="start"> | ||||
|             <ion-back-button></ion-back-button> | ||||
|         </ion-buttons> | ||||
| 
 | ||||
|         <ion-title>{{ 'core.login.connecttomoodle' | translate }}</ion-title> | ||||
| 
 | ||||
|         <ion-buttons slot="end"> | ||||
|             <!-- @todo: Settings button. --> | ||||
|         </ion-buttons> | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content padding> | ||||
|     <div> | ||||
|         <div text-center padding margin-bottom [class.hidden]="hasSites || enteredSiteUrl" class="core-login-site-logo"> | ||||
|             <img src="assets/img/login_logo.png" class="avatar-full login-logo" role="presentation"> | ||||
|         </div> | ||||
|         <form ion-list [formGroup]="siteForm" (ngSubmit)="connect($event, siteForm.value.siteUrl)" *ngIf="!fixedSites" #siteFormEl> | ||||
|             <!-- Form to input the site URL if there are no fixed sites. --> | ||||
|             <ng-container *ngIf="siteSelector == 'url'"> | ||||
|                 <ion-item> | ||||
|                     <ion-label position="stacked"><h2>{{ 'core.login.siteaddress' | translate }}</h2></ion-label> | ||||
|                     <ion-input name="url" type="url" placeholder="{{ 'core.login.siteaddressplaceholder' | translate }}" formControlName="siteUrl" [core-auto-focus]="showKeyboard && !showScanQR"></ion-input> | ||||
|                 </ion-item> | ||||
|             </ng-container> | ||||
|             <ng-container *ngIf="siteSelector != 'url'"> | ||||
|                 <ion-item> | ||||
|                     <ion-label position="stacked"><h2>{{ 'core.login.siteaddress' | translate }}</h2></ion-label> | ||||
|                     <ion-input name="url" placeholder="{{ 'core.login.siteaddressplaceholder' | translate }}" formControlName="siteUrl" [core-auto-focus]="showKeyboard && !showScanQR" (ionChange)="searchSite($event, siteForm.value.siteUrl)"></ion-input> | ||||
|                 </ion-item> | ||||
| 
 | ||||
|                 <ion-list [class.hidden]="!hasSites && !enteredSiteUrl" class="core-login-site-list"> | ||||
|                     <ion-item no-lines class="core-login-site-list-title"><h2 class="item-heading">{{ 'core.login.selectsite' | translate }}</h2></ion-item> | ||||
|                     <ion-item *ngIf="enteredSiteUrl" (click)="connect($event, enteredSiteUrl.url)" [attr.aria-label]="'core.login.connect' | translate" detail-push class="core-login-entered-site"> | ||||
|                         <ion-thumbnail item-start> | ||||
|                             <core-icon name="fa-pencil"></core-icon> | ||||
|                         </ion-thumbnail> | ||||
|                         <h2 text-wrap>{{ 'core.login.yourenteredsite' | translate }}</h2> | ||||
|                         <p>{{enteredSiteUrl.noProtocolUrl}}</p> | ||||
|                     </ion-item> | ||||
| 
 | ||||
|                     <div class="core-login-site-list-found" [class.hidden]="!hasSites" [class.dimmed]="loadingSites"> | ||||
|                         <div *ngIf="loadingSites" class="core-login-site-list-loading"><ion-spinner></ion-spinner></div> | ||||
|                         <ion-item *ngFor="let site of sites" (click)="connect($event, site.url, site)" [attr.aria-label]="site.name" detail-push> | ||||
|                             <ion-thumbnail item-start *ngIf="siteFinderSettings.displayimage"> | ||||
|                                 <img [src]="site.imageurl" *ngIf="site.imageurl" onError="this.src='assets/icon/icon.png'"> | ||||
|                                 <img src="assets/icon/icon.png" *ngIf="!site.imageurl" class="core-login-default-icon"> | ||||
|                             </ion-thumbnail> | ||||
|                             <h2 *ngIf="site.title" text-wrap>{{site.title}}</h2> | ||||
|                             <p *ngIf="site.noProtocolUrl">{{site.noProtocolUrl}}</p> | ||||
|                             <p *ngIf="site.location">{{site.location}}</p> | ||||
|                         </ion-item> | ||||
|                     </div> | ||||
|                 </ion-list> | ||||
| 
 | ||||
|                 <div *ngIf="!hasSites && loadingSites" class="core-login-site-nolist-loading"><ion-spinner></ion-spinner></div> | ||||
|             </ng-container> | ||||
| 
 | ||||
|             <ion-item *ngIf="siteSelector == 'url'" no-lines> | ||||
|                 <ion-button block [disabled]="!siteForm.valid" text-wrap>{{ 'core.login.connect' | translate }}</ion-button> | ||||
|             </ion-item> | ||||
|         </form> | ||||
| 
 | ||||
|         <ng-container *ngIf="fixedSites"> | ||||
|             <!-- Pick the site from a list of fixed sites. --> | ||||
|             <ion-list *ngIf="siteSelector == 'list'"> | ||||
|                 <ion-item no-lines><h2 class="item-heading">{{ 'core.login.selectsite' | translate }}</h2></ion-item> | ||||
|                 <ion-searchbar *ngIf="fixedSites.length > 4" [(ngModel)]="filter" (ionInput)="filterChanged($event)" (ionCancel)="filterChanged()" [placeholder]="'core.login.findyoursite' | translate"></ion-searchbar> | ||||
|                 <ion-item *ngFor="let site of filteredSites" (click)="connect($event, site.url)" [title]="site.name" detail-push> | ||||
|                     <ion-thumbnail item-start *ngIf="siteFinderSettings.displayimage"> | ||||
|                         <img [src]="site.imageurl" *ngIf="site.imageurl" onError="this.src='assets/icon/icon.png'"> | ||||
|                         <img src="assets/icon/icon.png" *ngIf="!site.imageurl" class="core-login-default-icon"> | ||||
|                     </ion-thumbnail> | ||||
|                     <h2 *ngIf="site.title" text-wrap>{{site.title}}</h2> | ||||
|                     <p *ngIf="site.noProtocolUrl">{{site.noProtocolUrl}}</p> | ||||
|                     <p *ngIf="site.location">{{site.location}}</p> | ||||
|                 </ion-item> | ||||
|             </ion-list> | ||||
| 
 | ||||
|             <!-- Display them using buttons. --> | ||||
|             <div *ngIf="siteSelector == 'buttons'"> | ||||
|                 <p class="padding no-padding-bottom">{{ 'core.login.selectsite' | translate }}</p> | ||||
|                 <ion-button *ngFor="let site of fixedSites" text-wrap block (click)="connect($event, site.url)" [title]="site.name" margin-bottom>{{site.title}}</ion-button> | ||||
|             </div> | ||||
|         </ng-container> | ||||
| 
 | ||||
|         <ng-container *ngIf="showScanQR && !hasSites && !enteredSiteUrl"> | ||||
|             <div class="core-login-site-qrcode-separator">{{ 'core.login.or' | translate }}</div> | ||||
|             <ion-item class="core-login-site-qrcode" no-lines> | ||||
|                 <ion-button block color="light" margin-top icon-start (click)="showInstructionsAndScanQR()" text-wrap> | ||||
|                     <core-icon name="fa-qrcode" aria-hidden="true"></core-icon> | ||||
|                     {{ 'core.scanqr' | translate }} | ||||
|                 </ion-button> | ||||
|             </ion-item> | ||||
|         </ng-container> | ||||
| 
 | ||||
|         <!-- Help. --> | ||||
|         <ion-list no-lines margin-top> | ||||
|             <ion-item text-center text-wrap class="core-login-need-help" (click)="showHelp()" detail-none> | ||||
|                 {{ 'core.needhelp' | translate }} | ||||
|             </ion-item> | ||||
|         </ion-list> | ||||
|     </div> | ||||
| </ion-content> | ||||
|  | ||||
| @ -12,7 +12,23 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { FormBuilder, FormGroup, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; | ||||
| 
 | ||||
| import { CoreApp } from '@services/app'; | ||||
| import { CoreConfig } from '@services/config'; | ||||
| import { CoreSites, CoreSiteCheckResponse, CoreLoginSiteInfo, CoreSitesDemoSiteData } from '@services/sites'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/helper'; | ||||
| import { CoreSite } from '@classes/site'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import CoreConfigConstants from '@app/config.json'; | ||||
| import { Translate } from '@singletons/core.singletons'; | ||||
| import { CoreUrl } from '@singletons/url'; | ||||
| import { CoreUrlUtils } from '@/app/services/utils/url'; | ||||
| import { NavController } from '@ionic/angular'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays a "splash screen" while the app is being initialized. | ||||
| @ -24,11 +40,476 @@ import { Component, OnInit } from '@angular/core'; | ||||
| }) | ||||
| export class CoreLoginSitePage implements OnInit { | ||||
| 
 | ||||
|     @ViewChild('siteFormEl') formElement?: ElementRef; | ||||
| 
 | ||||
|     siteForm: FormGroup; | ||||
|     fixedSites?: CoreLoginSiteInfoExtended[]; | ||||
|     filteredSites?: CoreLoginSiteInfoExtended[]; | ||||
|     siteSelector = 'sitefinder'; | ||||
|     showKeyboard = false; | ||||
|     filter = ''; | ||||
|     sites: CoreLoginSiteInfoExtended[] = []; | ||||
|     hasSites = false; | ||||
|     loadingSites = false; | ||||
|     searchFunction: (search: string) => void; | ||||
|     showScanQR: boolean; | ||||
|     enteredSiteUrl?: CoreLoginSiteInfoExtended; | ||||
|     siteFinderSettings: SiteFinderSettings; | ||||
| 
 | ||||
|     constructor( | ||||
|         protected route: ActivatedRoute, | ||||
|         protected formBuilder: FormBuilder, | ||||
|         protected navCtrl: NavController, | ||||
|     ) { | ||||
| 
 | ||||
|         let url = ''; | ||||
|         this.siteSelector = CoreConfigConstants.multisitesdisplay; | ||||
| 
 | ||||
|         const siteFinderSettings: Partial<SiteFinderSettings> = CoreConfigConstants['sitefindersettings'] || {}; | ||||
|         this.siteFinderSettings = { | ||||
|             displaysitename: true, | ||||
|             displayimage: true, | ||||
|             displayalias: true, | ||||
|             displaycity: true, | ||||
|             displaycountry: true, | ||||
|             displayurl: true, | ||||
|             ...siteFinderSettings, | ||||
|         }; | ||||
| 
 | ||||
|         // Load fixed sites if they're set.
 | ||||
|         if (CoreLoginHelper.instance.hasSeveralFixedSites()) { | ||||
|             url = this.initSiteSelector(); | ||||
|         } else if (CoreConfigConstants.enableonboarding && !CoreApp.instance.isIOS() && !CoreApp.instance.isMac()) { | ||||
|             this.initOnboarding(); | ||||
|         } | ||||
| 
 | ||||
|         this.showScanQR = CoreUtils.instance.canScanQR() && (typeof CoreConfigConstants['displayqronsitescreen'] == 'undefined' || | ||||
|             !!CoreConfigConstants['displayqronsitescreen']); | ||||
| 
 | ||||
|         this.siteForm = this.formBuilder.group({ | ||||
|             siteUrl: [url, this.moodleUrlValidator()], | ||||
|         }); | ||||
| 
 | ||||
|         this.searchFunction = CoreUtils.instance.debounce(async (search: string) => { | ||||
|             search = search.trim(); | ||||
| 
 | ||||
|             if (search.length >= 3) { | ||||
|                 // Update the sites list.
 | ||||
|                 const sites = await CoreSites.instance.findSites(search); | ||||
| 
 | ||||
|                 // Add UI tweaks.
 | ||||
|                 this.sites = this.extendCoreLoginSiteInfo(<CoreLoginSiteInfoExtended[]> sites); | ||||
| 
 | ||||
|                 this.hasSites = !!this.sites.length; | ||||
|             } else { | ||||
|                 // Not reseting the array to allow animation to be displayed.
 | ||||
|                 this.hasSites = false; | ||||
|             } | ||||
| 
 | ||||
|             this.loadingSites = false; | ||||
|         }, 1000); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize the component. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         //
 | ||||
|         this.route.queryParams.subscribe(params => { | ||||
|             this.showKeyboard = !!params['showKeyboard']; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize the site selector. | ||||
|      * | ||||
|      * @return URL of the first site. | ||||
|      */ | ||||
|     protected initSiteSelector(): string { | ||||
|         // Deprecate listnourl on 3.9.3, remove this block on the following release.
 | ||||
|         if (this.siteSelector == 'listnourl') { | ||||
|             this.siteSelector = 'list'; | ||||
|             this.siteFinderSettings.displayurl = false; | ||||
|         } | ||||
| 
 | ||||
|         this.fixedSites = this.extendCoreLoginSiteInfo(<CoreLoginSiteInfoExtended[]> CoreLoginHelper.instance.getFixedSites()); | ||||
| 
 | ||||
|         // Do not show images if none are set.
 | ||||
|         if (!this.fixedSites.some((site) => !!site.imageurl)) { | ||||
|             this.siteFinderSettings.displayimage = false; | ||||
|         } | ||||
| 
 | ||||
|         // Autoselect if not defined.
 | ||||
|         if (this.siteSelector != 'list' && this.siteSelector != 'buttons') { | ||||
|             this.siteSelector = this.fixedSites.length > 3 ? 'list' : 'buttons'; | ||||
|         } | ||||
| 
 | ||||
|         this.filteredSites = this.fixedSites; | ||||
| 
 | ||||
|         return this.fixedSites[0].url; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize and show onboarding if needed. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async initOnboarding(): Promise<void> { | ||||
|         const onboardingDone = await CoreConfig.instance.get(CoreLoginHelperProvider.ONBOARDING_DONE, false); | ||||
| 
 | ||||
|         if (!onboardingDone) { | ||||
|             // Check onboarding.
 | ||||
|             this.showOnboarding(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Extend info of Login Site Info to get UI tweaks. | ||||
|      * | ||||
|      * @param  sites Sites list. | ||||
|      * @return Sites list with extended info. | ||||
|      */ | ||||
|     protected extendCoreLoginSiteInfo(sites: CoreLoginSiteInfoExtended[]): CoreLoginSiteInfoExtended[] { | ||||
|         return sites.map((site) => { | ||||
|             site.noProtocolUrl = this.siteFinderSettings.displayurl && site.url ? CoreUrl.removeProtocol(site.url) : ''; | ||||
| 
 | ||||
|             const name = this.siteFinderSettings.displaysitename ? site.name : ''; | ||||
|             const alias = this.siteFinderSettings.displayalias && site.alias ? site.alias : ''; | ||||
| 
 | ||||
|             // Set title with parenthesis if both name and alias are present.
 | ||||
|             site.title = name && alias ? name + ' (' + alias + ')' : name + alias; | ||||
| 
 | ||||
|             const country = this.siteFinderSettings.displaycountry && site.countrycode ? | ||||
|                 CoreUtils.instance.getCountryName(site.countrycode) : ''; | ||||
|             const city = this.siteFinderSettings.displaycity && site.city ? | ||||
|                 site.city : ''; | ||||
| 
 | ||||
|             // Separate location with hiphen if both country and city are present.
 | ||||
|             site.location = city && country ? city + ' - ' + country : city + country; | ||||
| 
 | ||||
|             return site; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Validate Url. | ||||
|      * | ||||
|      * @return {ValidatorFn} Validation results. | ||||
|      */ | ||||
|     protected moodleUrlValidator(): ValidatorFn { | ||||
|         return (control: AbstractControl): ValidationErrors | null => { | ||||
|             const value = control.value.trim(); | ||||
|             let valid = value.length >= 3 && CoreUrl.isValidMoodleUrl(value); | ||||
| 
 | ||||
|             if (!valid) { | ||||
|                 const demo = !!CoreSites.instance.getDemoSiteData(value); | ||||
| 
 | ||||
|                 if (demo) { | ||||
|                     valid = true; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             return valid ? null : { siteUrl: { value: control.value } }; | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show a help modal. | ||||
|      */ | ||||
|     showHelp(): void { | ||||
|         // @todo
 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show an onboarding modal. | ||||
|      */ | ||||
|     showOnboarding(): void { | ||||
|         // @todo
 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Try to connect to a site. | ||||
|      * | ||||
|      * @param e Event. | ||||
|      * @param url The URL to connect to. | ||||
|      * @param foundSite The site clicked, if any, from the found sites list. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async connect(e: Event, url: string, foundSite?: CoreLoginSiteInfoExtended): Promise<void> { | ||||
|         e.preventDefault(); | ||||
|         e.stopPropagation(); | ||||
| 
 | ||||
|         CoreApp.instance.closeKeyboard(); | ||||
| 
 | ||||
|         if (!url) { | ||||
|             CoreDomUtils.instance.showErrorModal('core.login.siteurlrequired', true); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!CoreApp.instance.isOnline()) { | ||||
|             CoreDomUtils.instance.showErrorModal('core.networkerrormsg', true); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         url = url.trim(); | ||||
| 
 | ||||
|         if (url.match(/^(https?:\/\/)?campus\.example\.edu/)) { | ||||
|             this.showLoginIssue(null, new CoreError(Translate.instance.instant('core.login.errorexampleurl'))); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const siteData = CoreSites.instance.getDemoSiteData(url); | ||||
| 
 | ||||
|         if (siteData) { | ||||
|             // It's a demo site.
 | ||||
|             await this.loginDemoSite(siteData); | ||||
| 
 | ||||
|         } else { | ||||
|             // Not a demo site.
 | ||||
|             const modal = await CoreDomUtils.instance.showModalLoading(); | ||||
| 
 | ||||
|             let checkResult: CoreSiteCheckResponse; | ||||
| 
 | ||||
|             try { | ||||
|                 checkResult = await CoreSites.instance.checkSite(url); | ||||
|             } catch (error) { | ||||
|                 // Attempt guessing the domain if the initial check failed
 | ||||
|                 const domain = CoreUrl.guessMoodleDomain(url); | ||||
| 
 | ||||
|                 if (domain && domain != url) { | ||||
|                     try { | ||||
|                         checkResult = await CoreSites.instance.checkSite(domain); | ||||
|                     } catch (secondError) { | ||||
|                         // Try to use the first error.
 | ||||
|                         modal.dismiss(); | ||||
| 
 | ||||
|                         return this.showLoginIssue(url, error || secondError); | ||||
|                     } | ||||
|                 } else { | ||||
|                     modal.dismiss(); | ||||
| 
 | ||||
|                     return this.showLoginIssue(url, error); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             await this.login(checkResult, foundSite); | ||||
| 
 | ||||
|             modal.dismiss(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Authenticate in a demo site. | ||||
|      * | ||||
|      * @param siteData Site data. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async loginDemoSite(siteData: CoreSitesDemoSiteData): Promise<void> { | ||||
|         const modal = await CoreDomUtils.instance.showModalLoading(); | ||||
| 
 | ||||
|         try { | ||||
|             const data = await CoreSites.instance.getUserToken(siteData.url, siteData.username, siteData.password); | ||||
| 
 | ||||
|             await CoreSites.instance.newSite(data.siteUrl, data.token, data.privateToken); | ||||
| 
 | ||||
|             CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); | ||||
| 
 | ||||
|             return CoreLoginHelper.instance.goToSiteInitialPage(); | ||||
|         } catch (error) { | ||||
|             CoreLoginHelper.instance.treatUserTokenError(siteData.url, error, siteData.username, siteData.password); | ||||
| 
 | ||||
|             if (error.loggedout) { | ||||
|                 this.navCtrl.navigateRoot('/login/sites'); | ||||
|             } | ||||
|         } finally { | ||||
|             modal.dismiss(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Process login to a site. | ||||
|      * | ||||
|      * @param response Response obtained from the site check request. | ||||
|      * @param foundSite The site clicked, if any, from the found sites list. | ||||
|      * | ||||
|      * @return Promise resolved after logging in. | ||||
|      */ | ||||
|     protected async login(response: CoreSiteCheckResponse, foundSite?: CoreLoginSiteInfoExtended): Promise<void> { | ||||
|         await CoreUtils.instance.ignoreErrors(CoreSites.instance.checkApplication(response)); | ||||
| 
 | ||||
|         CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); | ||||
| 
 | ||||
|         if (response.warning) { | ||||
|             CoreDomUtils.instance.showErrorModal(response.warning, true, 4000); | ||||
|         } | ||||
| 
 | ||||
|         if (CoreLoginHelper.instance.isSSOLoginNeeded(response.code)) { | ||||
|             // SSO. User needs to authenticate in a browser.
 | ||||
|             CoreLoginHelper.instance.confirmAndOpenBrowserForSSOLogin( | ||||
|                 response.siteUrl, | ||||
|                 response.code, | ||||
|                 response.service, | ||||
|                 response.config?.launchurl, | ||||
|             ); | ||||
|         } else { | ||||
|             const pageParams = { siteUrl: response.siteUrl, siteConfig: response.config }; | ||||
|             if (foundSite) { | ||||
|                 pageParams['siteName'] = foundSite.name; | ||||
|                 pageParams['logoUrl'] = foundSite.imageurl; | ||||
|             } | ||||
| 
 | ||||
|             // @todo Navigate to credentials.
 | ||||
|             this.navCtrl.navigateForward('/login/credentials', { | ||||
|                 queryParams: pageParams, | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show an error that aims people to solve the issue. | ||||
|      * | ||||
|      * @param url The URL the user was trying to connect to. | ||||
|      * @param error Error to display. | ||||
|      */ | ||||
|     protected showLoginIssue(url: string | null, error: CoreError): void { | ||||
|         let errorMessage = CoreDomUtils.instance.getErrorMessage(error); | ||||
| 
 | ||||
|         if (errorMessage == Translate.instance.instant('core.cannotconnecttrouble')) { | ||||
|             const found = this.sites.find((site) => site.url == url); | ||||
| 
 | ||||
|             if (!found) { | ||||
|                 errorMessage += ' ' + Translate.instance.instant('core.cannotconnectverify'); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         let message = '<p>' + errorMessage + '</p>'; | ||||
|         if (url) { | ||||
|             const fullUrl = CoreUrlUtils.instance.isAbsoluteURL(url) ? url : 'https://' + url; | ||||
|             message += '<p padding><a href="' + fullUrl + '" core-link>' + url + '</a></p>'; | ||||
|         } | ||||
| 
 | ||||
|         const buttons = [ | ||||
|             { | ||||
|                 text: Translate.instance.instant('core.needhelp'), | ||||
|                 handler: (): void => { | ||||
|                     this.showHelp(); | ||||
|                 }, | ||||
|             }, | ||||
|             { | ||||
|                 text: Translate.instance.instant('core.tryagain'), | ||||
|                 role: 'cancel', | ||||
|             }, | ||||
|         ]; | ||||
| 
 | ||||
|         // @TODO: Remove CoreSite.MINIMUM_MOODLE_VERSION, not used on translations since 3.9.0.
 | ||||
|         CoreDomUtils.instance.showAlertWithOptions({ | ||||
|             header: Translate.instance.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), | ||||
|             message, | ||||
|             buttons, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * The filter has changed. | ||||
|      * | ||||
|      * @param event Received Event. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     filterChanged(event: any): void { | ||||
|         const newValue = event.target.value?.trim().toLowerCase(); | ||||
|         if (!newValue || !this.fixedSites) { | ||||
|             this.filteredSites = this.fixedSites; | ||||
|         } else { | ||||
|             this.filteredSites = this.fixedSites.filter((site) => | ||||
|                 site.title.toLowerCase().indexOf(newValue) > -1 || site.noProtocolUrl.toLowerCase().indexOf(newValue) > -1 || | ||||
|                 site.location.toLowerCase().indexOf(newValue) > -1); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Find a site on the backend. | ||||
|      * | ||||
|      * @param e Event. | ||||
|      * @param search Text to search. | ||||
|      */ | ||||
|     searchSite(e: Event, search: string): void { | ||||
|         this.loadingSites = true; | ||||
| 
 | ||||
|         search = search.trim(); | ||||
| 
 | ||||
|         if (this.siteForm.valid && search.length >= 3) { | ||||
|             this.enteredSiteUrl = { | ||||
|                 url: search, | ||||
|                 name: 'connect', | ||||
|                 title: '', | ||||
|                 location: '', | ||||
|                 noProtocolUrl: CoreUrl.removeProtocol(search), | ||||
|             }; | ||||
|         } else { | ||||
|             this.enteredSiteUrl = undefined; | ||||
|         } | ||||
| 
 | ||||
|         this.searchFunction(search.trim()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show instructions and scan QR code. | ||||
|      */ | ||||
|     showInstructionsAndScanQR(): void { | ||||
|         // Show some instructions first.
 | ||||
|         CoreDomUtils.instance.showAlertWithOptions({ | ||||
|             header: Translate.instance.instant('core.login.faqwhereisqrcode'), | ||||
|             message: Translate.instance.instant( | ||||
|                 'core.login.faqwhereisqrcodeanswer', | ||||
|                 { $image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML }, | ||||
|             ), | ||||
|             buttons: [ | ||||
|                 { | ||||
|                     text: Translate.instance.instant('core.cancel'), | ||||
|                     role: 'cancel', | ||||
|                 }, | ||||
|                 { | ||||
|                     text: Translate.instance.instant('core.next'), | ||||
|                     handler: (): void => { | ||||
|                         this.scanQR(); | ||||
|                     }, | ||||
|                 }, | ||||
|             ], | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Scan a QR code and put its text in the URL input. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async scanQR(): Promise<void> { | ||||
|         // Scan for a QR code.
 | ||||
|         const text = await CoreUtils.instance.scanQR(); | ||||
| 
 | ||||
|         if (text) { | ||||
|             // @todo
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Extended data for UI implementation. | ||||
|  */ | ||||
| type CoreLoginSiteInfoExtended = CoreLoginSiteInfo & { | ||||
|     noProtocolUrl: string; // Url wihtout protocol.
 | ||||
|     location: string; // City + country.
 | ||||
|     title: string; // Name + alias.
 | ||||
| }; | ||||
| 
 | ||||
| type SiteFinderSettings = { | ||||
|     displayalias: boolean; | ||||
|     displaycity: boolean; | ||||
|     displaycountry: boolean; | ||||
|     displayimage: boolean; | ||||
|     displaysitename: boolean; | ||||
|     displayurl: boolean; | ||||
| }; | ||||
|  | ||||
| @ -1,2 +1,130 @@ | ||||
| app-root page-core-login-init { | ||||
| .item-input:last-child { | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
| 
 | ||||
| .searchbar-ios { | ||||
|     background: transparent; | ||||
| 
 | ||||
|     .searchbar-input { | ||||
|         background-color: white; // @todo $searchbar-ios-toolbar-input-background; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| .item.item-block { | ||||
|     &.core-login-need-help.item { | ||||
|         text-decoration: underline; | ||||
|     } | ||||
|     &.core-login-site-qrcode { | ||||
|         .item-inner { | ||||
|             border-bottom: 0; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| .core-login-site-connect { | ||||
|     margin-top: 1.4rem; | ||||
| } | ||||
| 
 | ||||
| .item ion-thumbnail { | ||||
|     min-width: 50px; | ||||
|     min-height: 50px; | ||||
|     width: 50px; | ||||
|     height: 50px; | ||||
|     border-radius: 20%; | ||||
|     box-shadow: 0 0 4px #eee; | ||||
|     text-align: center; | ||||
|     overflow: hidden; | ||||
| 
 | ||||
|     img { | ||||
|         max-height: 50px; | ||||
|         max-width: fit-content; | ||||
|         width: auto; | ||||
|         height: auto; | ||||
|         margin: 0 auto; | ||||
|         margin-left: 50%; | ||||
|         transform: translateX(-50%); | ||||
|         object-fit: cover; | ||||
|         object-position: 50% 50%; | ||||
|     } | ||||
|     ion-icon { | ||||
|         margin: 0 auto; | ||||
|         font-size: 40px; | ||||
|         line-height: 50px; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| .core-login-site-logo, | ||||
| .core-login-site-list, | ||||
| .core-login-site-list-found { | ||||
|     transition-delay: 0s; | ||||
|     visibility: visible; | ||||
|     opacity: 1; | ||||
|     transition: all 0.7s ease-in-out; | ||||
|     max-height: 9999px; | ||||
| 
 | ||||
|     &.hidden { | ||||
|         opacity: 0; | ||||
|         visibility: hidden; | ||||
|         margin-top: 0; | ||||
|         margin-bottom: 0; | ||||
|         padding: 0; | ||||
|         max-height: 0; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| .core-login-site-list-found.dimmed { | ||||
|     pointer-events: none; | ||||
|     position: relative; | ||||
| } | ||||
| 
 | ||||
| .core-login-site-list-loading { | ||||
|     position: absolute; | ||||
|     //@todo @include position(0, 0, 0, 0); | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     display: flex; | ||||
|     align-content: center; | ||||
|     align-items: center; | ||||
|     background-color: rgba(255, 255, 255, 0.5); | ||||
|     z-index: 1; | ||||
|     ion-spinner { | ||||
|         flex: 1; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| .core-login-site-nolist-loading { | ||||
|     text-align: center; | ||||
| } | ||||
| 
 | ||||
| .item.core-login-site-list-title { | ||||
|     ion-label, ion-label h2.item-heading { | ||||
|         margin-top: 0; | ||||
|     } | ||||
| } | ||||
| /* @todo | ||||
| @include media-breakpoint-up(md) { | ||||
|     .scroll-content > * { | ||||
|         max-width: 600px; | ||||
|         margin: 0 auto; | ||||
|         width: 100%; | ||||
|     } | ||||
|     .core-login-site-logo { | ||||
|         margin-top: 20%; | ||||
|     } | ||||
| 
 | ||||
|     &.hidden { | ||||
|         margin: 0; | ||||
|     } | ||||
| } | ||||
| */ | ||||
| .core-login-entered-site { | ||||
|     background-color: gray; // @todo $gray-lighter; | ||||
|     ion-thumbnail { | ||||
|         box-shadow: 0 0 4px #ddd; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| .core-login-default-icon { | ||||
|     filter: grayscale(100%); | ||||
| } | ||||
|  | ||||
							
								
								
									
										37
									
								
								src/app/core/login/pages/sites/sites.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/app/core/login/pages/sites/sites.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | ||||
| <ion-header> | ||||
|     <ion-toolbar> | ||||
|         <ion-buttons slot="start"> | ||||
|             <ion-back-button></ion-back-button> | ||||
|         </ion-buttons> | ||||
| 
 | ||||
|         <ion-title>{{ 'core.settings.sites' | translate }}</ion-title> | ||||
| 
 | ||||
|         <ion-buttons slot="end"> | ||||
|             <!-- @todo: Settings button. --> | ||||
|             <ion-button *ngIf="sites && sites.length > 0" icon-only (click)="toggleDelete()" [attr.aria-label]="'core.delete' | translate"> | ||||
|                 <ion-icon name="create" ios="md-create"></ion-icon> | ||||
|             </ion-button> | ||||
|         </ion-buttons> | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content> | ||||
|     <ion-list> | ||||
|         <ion-item (click)="login(site.id)" *ngFor="let site of sites; let idx = index" detail-none> | ||||
|             <ion-avatar item-start> | ||||
|                 <img [src]="site.avatar" core-external-content [siteId]="site.id" alt="{{ 'core.pictureof' | translate:{$a: site.fullName} }}" role="presentation" onError="this.src='assets/img/user-avatar.png'"> | ||||
|             </ion-avatar> | ||||
|             <h2>{{site.fullName}}</h2> | ||||
|             <p><core-format-text [text]="site.siteName" clean="true" [siteId]="site.id"></core-format-text></p> | ||||
|             <p>{{site.siteUrl}}</p> | ||||
|             <ion-badge item-end *ngIf="!showDelete && site.badge">{{site.badge}}</ion-badge> | ||||
|             <ion-button *ngIf="showDelete" item-end icon-only clear color="danger" (click)="deleteSite($event, idx)" [attr.aria-label]="'core.delete' | translate"> | ||||
|                 <ion-icon name="trash"></ion-icon> | ||||
|             </ion-button> | ||||
|         </ion-item> | ||||
|     </ion-list> | ||||
|     <ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end"> | ||||
|         <ion-fab-button (click)="add()" [attr.aria-label]="'core.add' | translate"> | ||||
|             <ion-icon name="add"></ion-icon> | ||||
|         </ion-fab-button> | ||||
|     </ion-fab> | ||||
| </ion-content> | ||||
							
								
								
									
										145
									
								
								src/app/core/login/pages/sites/sites.page.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								src/app/core/login/pages/sites/sites.page.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,145 @@ | ||||
| // (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 { CoreDomUtils } from '@/app/services/utils/dom'; | ||||
| import { CoreUtils } from '@/app/services/utils/utils'; | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| 
 | ||||
| import { CoreSiteBasicInfo, CoreSites } from '@services/sites'; | ||||
| import { CoreLogger } from '@singletons/logger'; | ||||
| import { CoreLoginHelper } from '../../services/helper'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays a "splash screen" while the app is being initialized. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'page-core-login-sites', | ||||
|     templateUrl: 'sites.html', | ||||
|     styleUrls: ['sites.scss'], | ||||
| }) | ||||
| export class CoreLoginSitesPage implements OnInit { | ||||
| 
 | ||||
|     sites: CoreSiteBasicInfo[] = []; | ||||
|     showDelete = false; | ||||
| 
 | ||||
|     protected logger: CoreLogger; | ||||
| 
 | ||||
|     constructor() { | ||||
|         this.logger = CoreLogger.getInstance('CoreLoginSitesPage'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         const sites = await CoreUtils.instance.ignoreErrors(CoreSites.instance.getSortedSites()); | ||||
| 
 | ||||
|         if (!sites || sites.length == 0) { | ||||
|             CoreLoginHelper.instance.goToAddSite(true); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Remove protocol from the url to show more url text.
 | ||||
|         this.sites = sites.map((site) => { | ||||
|             site.siteUrl = site.siteUrl.replace(/^https?:\/\//, ''); | ||||
|             site.badge = 0; | ||||
|             // @todo: getSiteCounter.
 | ||||
| 
 | ||||
|             return site; | ||||
|         }); | ||||
| 
 | ||||
|         this.showDelete = false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Go to the page to add a site. | ||||
|      */ | ||||
|     add(): void { | ||||
|         CoreLoginHelper.instance.goToAddSite(false, true); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Delete a site. | ||||
|      * | ||||
|      * @param e Click event. | ||||
|      * @param index Position of the site. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async deleteSite(e: Event, index: number): Promise<void> { | ||||
|         e.stopPropagation(); | ||||
| 
 | ||||
|         const site = this.sites[index]; | ||||
|         const siteName = site.siteName || ''; | ||||
| 
 | ||||
|         // @todo: Format text: siteName.
 | ||||
| 
 | ||||
|         try { | ||||
|             await CoreDomUtils.instance.showDeleteConfirm('core.login.confirmdeletesite', { sitename: siteName }); | ||||
|         } catch (error) { | ||||
|             // User cancelled, stop.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             await CoreSites.instance.deleteSite(site.id); | ||||
| 
 | ||||
|             this.sites.splice(index, 1); | ||||
|             this.showDelete = false; | ||||
| 
 | ||||
|             // If there are no sites left, go to add site.
 | ||||
|             const hasSites = await CoreSites.instance.hasSites(); | ||||
| 
 | ||||
|             if (!hasSites) { | ||||
|                 CoreLoginHelper.instance.goToAddSite(true, true); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             this.logger.error('Error deleting site ' + site.id, error); | ||||
|             CoreDomUtils.instance.showErrorModalDefault(error, 'core.login.errordeletesite', true); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Login in a site. | ||||
|      * | ||||
|      * @param siteId The site ID. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async login(siteId: string): Promise<void> { | ||||
|         const modal = await CoreDomUtils.instance.showModalLoading(); | ||||
| 
 | ||||
|         try { | ||||
|             const loggedIn = await CoreSites.instance.loadSite(siteId); | ||||
| 
 | ||||
|             if (loggedIn) { | ||||
|                 return CoreLoginHelper.instance.goToSiteInitialPage(); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             this.logger.error('Error loading site ' + siteId, error); | ||||
|             CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading site.'); | ||||
|         } finally { | ||||
|             modal.dismiss(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Toggle delete. | ||||
|      */ | ||||
|     toggleDelete(): void { | ||||
|         this.showDelete = !this.showDelete; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										3
									
								
								src/app/core/login/pages/sites/sites.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/app/core/login/pages/sites/sites.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| .item-ios .item-button[icon-only] ion-icon { | ||||
|     font-size: 2.1em; | ||||
| } | ||||
							
								
								
									
										1372
									
								
								src/app/core/login/services/helper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1372
									
								
								src/app/core/login/services/helper.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										75
									
								
								src/app/directives/auto-focus.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/app/directives/auto-focus.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | ||||
| // (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 { Directive, Input, OnInit, ElementRef } from '@angular/core'; | ||||
| 
 | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| 
 | ||||
| /** | ||||
|  * Directive to auto focus an element when a view is loaded. | ||||
|  * | ||||
|  * You can apply it conditionallity assigning it a boolean value: <ion-input [core-auto-focus]="{{showKeyboard}}"> | ||||
|  */ | ||||
| @Directive({ | ||||
|     selector: '[core-auto-focus]', | ||||
| }) | ||||
| export class CoreAutoFocusDirective implements OnInit { | ||||
| 
 | ||||
|     @Input('core-auto-focus') coreAutoFocus: boolean | string = true; | ||||
| 
 | ||||
|     protected element: HTMLElement; | ||||
| 
 | ||||
|     constructor(element: ElementRef) { | ||||
|         this.element = element.nativeElement; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         // @todo
 | ||||
|         // if (this.navCtrl.isTransitioning()) {
 | ||||
|         //     // Navigating to a new page. Wait for the transition to be over.
 | ||||
|         //     const subscription = this.navCtrl.viewDidEnter.subscribe(() => {
 | ||||
|         //         this.autoFocus();
 | ||||
|         //         subscription.unsubscribe();
 | ||||
|         //     });
 | ||||
|         // } else {
 | ||||
|         this.autoFocus(); | ||||
|         // }
 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Function after the view is initialized. | ||||
|      */ | ||||
|     protected autoFocus(): void { | ||||
|         const autoFocus = CoreUtils.instance.isTrueOrOne(this.coreAutoFocus); | ||||
|         if (autoFocus) { | ||||
|             // Wait a bit to make sure the view is loaded.
 | ||||
|             setTimeout(() => { | ||||
|                 // If it's a ion-input or ion-textarea, search the right input to use.
 | ||||
|                 let element = this.element; | ||||
|                 if (this.element.tagName == 'ION-INPUT') { | ||||
|                     element = this.element.querySelector('input') || element; | ||||
|                 } else if (this.element.tagName == 'ION-TEXTAREA') { | ||||
|                     element = this.element.querySelector('textarea') || element; | ||||
|                 } | ||||
| 
 | ||||
|                 CoreDomUtils.instance.focusElement(element); | ||||
|             }, 200); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -13,15 +13,28 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { NgModule } from '@angular/core'; | ||||
| 
 | ||||
| import { CoreAutoFocusDirective } from './auto-focus'; | ||||
| import { CoreExternalContentDirective } from './external-content'; | ||||
| import { CoreFormatTextDirective } from './format-text'; | ||||
| import { CoreLongPressDirective } from './long-press.directive'; | ||||
| import { CoreSupressEventsDirective } from './supress-events'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
|         CoreAutoFocusDirective, | ||||
|         CoreExternalContentDirective, | ||||
|         CoreFormatTextDirective, | ||||
|         CoreLongPressDirective, | ||||
|         CoreSupressEventsDirective, | ||||
|     ], | ||||
|     imports: [], | ||||
|     exports: [ | ||||
|         CoreAutoFocusDirective, | ||||
|         CoreExternalContentDirective, | ||||
|         CoreFormatTextDirective, | ||||
|         CoreLongPressDirective, | ||||
|         CoreSupressEventsDirective, | ||||
|     ], | ||||
| }) | ||||
| export class CoreDirectivesModule {} | ||||
|  | ||||
							
								
								
									
										364
									
								
								src/app/directives/external-content.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										364
									
								
								src/app/directives/external-content.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,364 @@ | ||||
| // (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 { Directive, Input, AfterViewInit, ElementRef, OnChanges, SimpleChange, Output, EventEmitter } from '@angular/core'; | ||||
| 
 | ||||
| import { CoreApp } from '@services/app'; | ||||
| import { CoreFile } from '@services/file'; | ||||
| import { CoreFilepool } from '@services/filepool'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUrlUtils } from '@services/utils/url'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { Platform } from '@singletons/core.singletons'; | ||||
| import { CoreLogger } from '@singletons/logger'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| 
 | ||||
| /** | ||||
|  * Directive to handle external content. | ||||
|  * | ||||
|  * This directive should be used with any element that links to external content | ||||
|  * which we want to have available when the app is offline. Typically media and links. | ||||
|  * | ||||
|  * If a file is downloaded, its URL will be replaced by the local file URL. | ||||
|  * | ||||
|  * From v3.5.2 this directive will also download inline styles, so it can be used in any element as long as it has inline styles. | ||||
|  */ | ||||
| @Directive({ | ||||
|     selector: '[core-external-content]', | ||||
| }) | ||||
| export class CoreExternalContentDirective implements AfterViewInit, OnChanges { | ||||
| 
 | ||||
|     @Input() siteId?: string; // Site ID to use.
 | ||||
|     @Input() component?: string; // Component to link the file to.
 | ||||
|     @Input() componentId?: string | number; // Component ID to use in conjunction with the component.
 | ||||
|     @Input() src?: string; | ||||
|     @Input() href?: string; | ||||
|     @Input('target-src') targetSrc?: string; // eslint-disable-line @angular-eslint/no-input-rename
 | ||||
|     @Input() poster?: string; | ||||
|     // eslint-disable-next-line @angular-eslint/no-output-on-prefix
 | ||||
|     @Output() onLoad = new EventEmitter(); // Emitted when content is loaded. Only for images.
 | ||||
| 
 | ||||
|     loaded = false; | ||||
|     invalid = false; | ||||
|     protected element: Element; | ||||
|     protected logger: CoreLogger; | ||||
|     protected initialized = false; | ||||
| 
 | ||||
|     constructor(element: ElementRef) { | ||||
| 
 | ||||
|         this.element = element.nativeElement; | ||||
|         this.logger = CoreLogger.getInstance('CoreExternalContentDirective'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * View has been initialized | ||||
|      */ | ||||
|     ngAfterViewInit(): void { | ||||
|         this.checkAndHandleExternalContent(); | ||||
| 
 | ||||
|         this.initialized = true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Listen to changes. | ||||
|      * | ||||
|      * * @param {{[name: string]: SimpleChange}} changes Changes. | ||||
|      */ | ||||
|     ngOnChanges(changes: { [name: string]: SimpleChange }): void { | ||||
|         if (changes && this.initialized) { | ||||
|             // If any of the inputs changes, handle the content again.
 | ||||
|             this.checkAndHandleExternalContent(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Add a new source with a certain URL as a sibling of the current element. | ||||
|      * | ||||
|      * @param url URL to use in the source. | ||||
|      */ | ||||
|     protected addSource(url: string): void { | ||||
|         if (this.element.tagName !== 'SOURCE') { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const newSource = document.createElement('source'); | ||||
|         const type = this.element.getAttribute('type'); | ||||
| 
 | ||||
|         newSource.setAttribute('src', url); | ||||
| 
 | ||||
|         if (type) { | ||||
|             if (CoreApp.instance.isAndroid() && type == 'video/quicktime') { | ||||
|                 // Fix for VideoJS/Chrome bug https://github.com/videojs/video.js/issues/423 .
 | ||||
|                 newSource.setAttribute('type', 'video/mp4'); | ||||
|             } else { | ||||
|                 newSource.setAttribute('type', type); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         this.element.parentNode?.insertBefore(newSource, this.element); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the URL that should be handled and, if valid, handle it. | ||||
|      */ | ||||
|     protected async checkAndHandleExternalContent(): Promise<void> { | ||||
|         const currentSite = CoreSites.instance.getCurrentSite(); | ||||
|         const siteId = this.siteId || currentSite?.getId(); | ||||
|         const tagName = this.element.tagName.toUpperCase(); | ||||
|         let targetAttr; | ||||
|         let url; | ||||
| 
 | ||||
|         // Always handle inline styles (if any).
 | ||||
|         this.handleInlineStyles(siteId); | ||||
| 
 | ||||
|         if (tagName === 'A' || tagName == 'IMAGE') { | ||||
|             targetAttr = 'href'; | ||||
|             url = this.href; | ||||
| 
 | ||||
|         } else if (tagName === 'IMG') { | ||||
|             targetAttr = 'src'; | ||||
|             url = this.src; | ||||
| 
 | ||||
|         } else if (tagName === 'AUDIO' || tagName === 'VIDEO' || tagName === 'SOURCE' || tagName === 'TRACK') { | ||||
|             targetAttr = 'src'; | ||||
|             url = this.targetSrc || this.src; | ||||
| 
 | ||||
|             if (tagName === 'VIDEO') { | ||||
|                 if (this.poster) { | ||||
|                     // Handle poster.
 | ||||
|                     this.handleExternalContent('poster', this.poster, siteId).catch(() => { | ||||
|                         // Ignore errors.
 | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|         } else { | ||||
|             this.invalid = true; | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Avoid handling data url's.
 | ||||
|         if (url && url.indexOf('data:') === 0) { | ||||
|             this.invalid = true; | ||||
|             this.onLoad.emit(); | ||||
|             this.loaded = true; | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             await this.handleExternalContent(targetAttr, url, siteId); | ||||
|         } catch (error) { | ||||
|             // Error handling content. Make sure the loaded event is triggered for images.
 | ||||
|             if (tagName === 'IMG') { | ||||
|                 if (url) { | ||||
|                     this.waitForLoad(); | ||||
|                 } else { | ||||
|                     this.onLoad.emit(); | ||||
|                     this.loaded = true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Handle external content, setting the right URL. | ||||
|      * | ||||
|      * @param targetAttr Attribute to modify. | ||||
|      * @param url Original URL to treat. | ||||
|      * @param siteId Site ID. | ||||
|      * @return Promise resolved if the element is successfully treated. | ||||
|      */ | ||||
|     protected async handleExternalContent(targetAttr: string, url: string, siteId?: string): Promise<void> { | ||||
| 
 | ||||
|         const tagName = this.element.tagName; | ||||
| 
 | ||||
|         if (tagName == 'VIDEO' && targetAttr != 'poster') { | ||||
|             const video = <HTMLVideoElement> this.element; | ||||
|             if (video.textTracks) { | ||||
|                 // It's a video with subtitles. In iOS, subtitles position is wrong so it needs to be fixed.
 | ||||
|                 video.textTracks.onaddtrack = (event): void => { | ||||
|                     const track = <TextTrack> event.track; | ||||
|                     if (track) { | ||||
|                         track.oncuechange = (): void => { | ||||
|                             if (!track.cues) { | ||||
|                                 return; | ||||
|                             } | ||||
| 
 | ||||
|                             const line = Platform.instance.is('tablet') || CoreApp.instance.isAndroid() ? 90 : 80; | ||||
|                             // Position all subtitles to a percentage of video height.
 | ||||
|                             // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|                             Array.from(track.cues).forEach((cue: any) => { | ||||
|                                 cue.snapToLines = false; | ||||
|                                 cue.line = line; | ||||
|                                 cue.size = 100; // This solves some Android issue.
 | ||||
|                             }); | ||||
|                             // Delete listener.
 | ||||
|                             track.oncuechange = null; | ||||
|                         }; | ||||
|                     } | ||||
|                 }; | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         if (!url || !url.match(/^https?:\/\//i) || CoreUrlUtils.instance.isLocalFileUrl(url) || | ||||
|                 (tagName === 'A' && !CoreUrlUtils.instance.isDownloadableUrl(url))) { | ||||
| 
 | ||||
|             this.logger.debug('Ignoring non-downloadable URL: ' + url); | ||||
|             if (tagName === 'SOURCE') { | ||||
|                 // Restoring original src.
 | ||||
|                 this.addSource(url); | ||||
|             } | ||||
| 
 | ||||
|             throw new CoreError('Non-downloadable URL'); | ||||
|         } | ||||
| 
 | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
| 
 | ||||
|         if (!site.canDownloadFiles() && CoreUrlUtils.instance.isPluginFileUrl(url)) { | ||||
|             this.element.parentElement?.removeChild(this.element); // Remove element since it'll be broken.
 | ||||
| 
 | ||||
|             throw 'Site doesn\'t allow downloading files.'; | ||||
|         } | ||||
| 
 | ||||
|         // Download images, tracks and posters if size is unknown.
 | ||||
|         const downloadUnknown = tagName == 'IMG' || tagName == 'TRACK' || targetAttr == 'poster'; | ||||
|         let finalUrl: string; | ||||
| 
 | ||||
|         if (targetAttr === 'src' && tagName !== 'SOURCE' && tagName !== 'TRACK' && tagName !== 'VIDEO' && tagName !== 'AUDIO') { | ||||
|             finalUrl = await CoreFilepool.instance.getSrcByUrl( | ||||
|                 site.getId(), | ||||
|                 url, | ||||
|                 this.component, | ||||
|                 this.componentId, | ||||
|                 0, | ||||
|                 true, | ||||
|                 downloadUnknown, | ||||
|             ); | ||||
|         } else { | ||||
|             finalUrl = await CoreFilepool.instance.getUrlByUrl( | ||||
|                 site.getId(), | ||||
|                 url, | ||||
|                 this.component, | ||||
|                 this.componentId, | ||||
|                 0, | ||||
|                 true, | ||||
|                 downloadUnknown, | ||||
|             ); | ||||
| 
 | ||||
|             finalUrl = CoreFile.instance.convertFileSrc(finalUrl); | ||||
|         } | ||||
| 
 | ||||
|         if (!CoreUrlUtils.instance.isLocalFileUrl(finalUrl)) { | ||||
|             /* In iOS, if we use the same URL in embedded file and background download then the download only | ||||
|                downloads a few bytes (cached ones). Add a hash to the URL so both URLs are different. */ | ||||
|             finalUrl = finalUrl + '#moodlemobile-embedded'; | ||||
|         } | ||||
| 
 | ||||
|         this.logger.debug('Using URL ' + finalUrl + ' for ' + url); | ||||
|         if (tagName === 'SOURCE') { | ||||
|             // The browser does not catch changes in SRC, we need to add a new source.
 | ||||
|             this.addSource(finalUrl); | ||||
|         } else { | ||||
|             if (tagName === 'IMG') { | ||||
|                 this.loaded = false; | ||||
|                 this.waitForLoad(); | ||||
|             } | ||||
|             this.element.setAttribute(targetAttr, finalUrl); | ||||
|             this.element.setAttribute('data-original-' + targetAttr, url); | ||||
|         } | ||||
| 
 | ||||
|         // Set events to download big files (not downloaded automatically).
 | ||||
|         if (!CoreUrlUtils.instance.isLocalFileUrl(finalUrl) && targetAttr != 'poster' && | ||||
|             (tagName == 'VIDEO' || tagName == 'AUDIO' || tagName == 'A' || tagName == 'SOURCE')) { | ||||
|             const eventName = tagName == 'A' ? 'click' : 'play'; | ||||
|             let clickableEl = this.element; | ||||
| 
 | ||||
|             if (tagName == 'SOURCE') { | ||||
|                 clickableEl = <HTMLElement> CoreDomUtils.instance.closest(this.element, 'video,audio'); | ||||
|                 if (!clickableEl) { | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             clickableEl.addEventListener(eventName, () => { | ||||
|                 // User played media or opened a downloadable link.
 | ||||
|                 // Download the file if in wifi and it hasn't been downloaded already (for big files).
 | ||||
|                 if (CoreApp.instance.isWifi()) { | ||||
|                     // We aren't using the result, so it doesn't matter which of the 2 functions we call.
 | ||||
|                     CoreFilepool.instance.getUrlByUrl(site.getId(), url, this.component, this.componentId, 0, false); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Handle inline styles, trying to download referenced files. | ||||
|      * | ||||
|      * @param siteId Site ID. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async handleInlineStyles(siteId?: string): Promise<void> { | ||||
|         if (!siteId) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         let inlineStyles = this.element.getAttribute('style'); | ||||
| 
 | ||||
|         if (!inlineStyles) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         let urls = inlineStyles.match(/https?:\/\/[^"') ;]*/g); | ||||
|         if (!urls || !urls.length) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         urls = CoreUtils.instance.uniqueArray(urls); // Remove duplicates.
 | ||||
| 
 | ||||
|         const promises = urls.map(async (url) => { | ||||
|             const finalUrl = await CoreFilepool.instance.getUrlByUrl(siteId, url, this.component, this.componentId, 0, true, true); | ||||
| 
 | ||||
|             this.logger.debug('Using URL ' + finalUrl + ' for ' + url + ' in inline styles'); | ||||
|             inlineStyles = inlineStyles!.replace(new RegExp(url, 'gi'), finalUrl); | ||||
|         }); | ||||
| 
 | ||||
|         try { | ||||
|             await CoreUtils.instance.allPromises(promises); | ||||
| 
 | ||||
|             this.element.setAttribute('style', inlineStyles); | ||||
|         } catch (error) { | ||||
|             this.logger.error('Error treating inline styles.', this.element); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Wait for the image to be loaded or error, and emit an event when it happens. | ||||
|      */ | ||||
|     protected waitForLoad(): void { | ||||
|         const listener = (): void => { | ||||
|             this.element.removeEventListener('load', listener); | ||||
|             this.element.removeEventListener('error', listener); | ||||
|             this.onLoad.emit(); | ||||
|             this.loaded = true; | ||||
|         }; | ||||
| 
 | ||||
|         this.element.addEventListener('load', listener); | ||||
|         this.element.addEventListener('error', listener); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										745
									
								
								src/app/directives/format-text.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										745
									
								
								src/app/directives/format-text.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,745 @@ | ||||
| // (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 { Directive, ElementRef, Input, Output, EventEmitter, OnChanges, SimpleChange, Optional } from '@angular/core'; | ||||
| import { NavController, IonContent } from '@ionic/angular'; | ||||
| 
 | ||||
| import { CoreEventLoadingChangedData, CoreEventObserver, CoreEvents, CoreEventsProvider } from '@services/events'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreIframeUtils, CoreIframeUtilsProvider } from '@services/utils/iframe'; | ||||
| import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreSite } from '@classes/site'; | ||||
| import { Translate } from '@singletons/core.singletons'; | ||||
| import { CoreExternalContentDirective } from './external-content'; | ||||
| 
 | ||||
| /** | ||||
|  * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective | ||||
|  * and CoreExternalContentDirective. It also applies filters if needed. | ||||
|  * | ||||
|  * Please use this directive if your text needs to be filtered or it can contain links or media (images, audio, video). | ||||
|  * | ||||
|  * Example usage: | ||||
|  * <core-format-text [text]="myText" [component]="component" [componentId]="componentId"></core-format-text> | ||||
|  */ | ||||
| @Directive({ | ||||
|     selector: 'core-format-text', | ||||
| }) | ||||
| export class CoreFormatTextDirective implements OnChanges { | ||||
| 
 | ||||
|     @Input() text?: string; // The text to format.
 | ||||
|     @Input() siteId?: string; // Site ID to use.
 | ||||
|     @Input() component?: string; // Component for CoreExternalContentDirective.
 | ||||
|     @Input() componentId?: string | number; // Component ID to use in conjunction with the component.
 | ||||
|     @Input() adaptImg?: boolean | string = true; // Whether to adapt images to screen width.
 | ||||
|     @Input() clean?: boolean | string; // Whether all the HTML tags should be removed.
 | ||||
|     @Input() singleLine?: boolean | string; // Whether new lines should be removed (all text in single line). Only if clean=true.
 | ||||
|     @Input() maxHeight?: number; // Max height in pixels to render the content box. It should be 50 at least to make sense.
 | ||||
|                                  // Using this parameter will force display: block to calculate height better.
 | ||||
|                                  // If you want to avoid this use class="inline" at the same time to use display: inline-block.
 | ||||
|     @Input() fullOnClick?: boolean | string; // Whether it should open a new page with the full contents on click.
 | ||||
|     @Input() fullTitle?: string; // Title to use in full view. Defaults to "Description".
 | ||||
|     @Input() highlight?: string; // Text to highlight.
 | ||||
|     @Input() filter?: boolean | string; // Whether to filter the text. If not defined, true if contextLevel and instanceId are set.
 | ||||
|     @Input() contextLevel?: string; // The context level of the text.
 | ||||
|     @Input() contextInstanceId?: number; // The instance ID related to the context.
 | ||||
|     @Input() courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters.
 | ||||
|     @Input() wsNotFiltered?: boolean | string; // If true it means the WS didn't filter the text for some reason.
 | ||||
|     @Output() afterRender: EventEmitter<void>; // Called when the data is rendered.
 | ||||
| 
 | ||||
|     protected element: HTMLElement; | ||||
|     protected showMoreDisplayed = false; | ||||
|     protected loadingChangedListener?: CoreEventObserver; | ||||
| 
 | ||||
|     constructor( | ||||
|         element: ElementRef, | ||||
|         @Optional() protected navCtrl: NavController, | ||||
|         @Optional() protected content: IonContent, | ||||
|     ) { | ||||
| 
 | ||||
|         this.element = element.nativeElement; | ||||
|         this.element.classList.add('opacity-hide'); // Hide contents until they're treated.
 | ||||
|         this.afterRender = new EventEmitter<void>(); | ||||
| 
 | ||||
|         this.element.addEventListener('click', this.elementClicked.bind(this)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Detect changes on input properties. | ||||
|      */ | ||||
|     ngOnChanges(changes: { [name: string]: SimpleChange }): void { | ||||
|         if (changes.text || changes.filter || changes.contextLevel || changes.contextInstanceId) { | ||||
|             this.hideShowMore(); | ||||
|             this.formatAndRenderContents(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Apply CoreExternalContentDirective to a certain element. | ||||
|      * | ||||
|      * @param element Element to add the attributes to. | ||||
|      * @return External content instance. | ||||
|      */ | ||||
|     protected addExternalContent(element: Element): CoreExternalContentDirective { | ||||
|         // Angular doesn't let adding directives dynamically. Create the CoreExternalContentDirective manually.
 | ||||
|         const extContent = new CoreExternalContentDirective(new ElementRef(element)); | ||||
| 
 | ||||
|         extContent.component = this.component; | ||||
|         extContent.componentId = this.componentId; | ||||
|         extContent.siteId = this.siteId; | ||||
|         extContent.src = element.getAttribute('src') || undefined; | ||||
|         extContent.href = element.getAttribute('href') || element.getAttribute('xlink:href') || undefined; | ||||
|         extContent.targetSrc = element.getAttribute('target-src') || undefined; | ||||
|         extContent.poster = element.getAttribute('poster') || undefined; | ||||
| 
 | ||||
|         extContent.ngAfterViewInit(); | ||||
| 
 | ||||
|         return extContent; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Add class to adapt media to a certain element. | ||||
|      * | ||||
|      * @param element Element to add the class to. | ||||
|      */ | ||||
|     protected addMediaAdaptClass(element: HTMLElement): void { | ||||
|         element.classList.add('core-media-adapt-width'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Wrap an image with a container to adapt its width. | ||||
|      * | ||||
|      * @param img Image to adapt. | ||||
|      */ | ||||
|     protected adaptImage(img: HTMLElement): void { | ||||
|         // Element to wrap the image.
 | ||||
|         const container = document.createElement('span'); | ||||
|         const originalWidth = img.attributes.getNamedItem('width'); | ||||
| 
 | ||||
|         const forcedWidth = Number(originalWidth?.value); | ||||
|         if (!isNaN(forcedWidth)) { | ||||
|             if (originalWidth!.value.indexOf('%') < 0) { | ||||
|                 img.style.width = forcedWidth  + 'px'; | ||||
|             } else { | ||||
|                 img.style.width = forcedWidth  + '%'; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         container.classList.add('core-adapted-img-container'); | ||||
|         container.style.cssFloat = img.style.cssFloat; // Copy the float to correctly position the search icon.
 | ||||
|         if (img.classList.contains('atto_image_button_right')) { | ||||
|             container.classList.add('atto_image_button_right'); | ||||
|         } else if (img.classList.contains('atto_image_button_left')) { | ||||
|             container.classList.add('atto_image_button_left'); | ||||
|         } else if (img.classList.contains('atto_image_button_text-top')) { | ||||
|             container.classList.add('atto_image_button_text-top'); | ||||
|         } else if (img.classList.contains('atto_image_button_middle')) { | ||||
|             container.classList.add('atto_image_button_middle'); | ||||
|         } else if (img.classList.contains('atto_image_button_text-bottom')) { | ||||
|             container.classList.add('atto_image_button_text-bottom'); | ||||
|         } | ||||
| 
 | ||||
|         CoreDomUtils.instance.wrapElement(img, container); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Add magnifying glass icons to view adapted images at full size. | ||||
|      */ | ||||
|     addMagnifyingGlasses(): void { | ||||
|         const imgs = Array.from(this.element.querySelectorAll('.core-adapted-img-container > img')); | ||||
|         if (!imgs.length) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // If cannot calculate element's width, use viewport width to avoid false adapt image icons appearing.
 | ||||
|         const elWidth = this.getElementWidth(this.element) || window.innerWidth; | ||||
| 
 | ||||
|         imgs.forEach((img: HTMLImageElement) => { | ||||
|             // Skip image if it's inside a link.
 | ||||
|             if (img.closest('a')) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             let imgWidth = Number(img.getAttribute('width')); | ||||
|             if (!imgWidth) { | ||||
|                 // No width attribute, use real size.
 | ||||
|                 imgWidth = img.naturalWidth; | ||||
|             } | ||||
| 
 | ||||
|             if (imgWidth <= elWidth) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             const imgSrc = CoreTextUtils.instance.escapeHTML(img.getAttribute('data-original-src') || img.getAttribute('src')); | ||||
|             const label = Translate.instance.instant('core.openfullimage'); | ||||
|             const anchor = document.createElement('a'); | ||||
| 
 | ||||
|             anchor.classList.add('core-image-viewer-icon'); | ||||
|             anchor.setAttribute('aria-label', label); | ||||
|             // Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed.
 | ||||
|             anchor.innerHTML = '<ion-icon name="search" class="icon icon-md ion-md-search"></ion-icon>'; | ||||
| 
 | ||||
|             anchor.addEventListener('click', (e: Event) => { | ||||
|                 e.preventDefault(); | ||||
|                 e.stopPropagation(); | ||||
|                 CoreDomUtils.instance.viewImage(imgSrc, img.getAttribute('alt'), this.component, this.componentId, true); | ||||
|             }); | ||||
| 
 | ||||
|             img.parentNode?.appendChild(anchor); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate the height and check if we need to display show more or not. | ||||
|      */ | ||||
|     protected calculateHeight(): void { | ||||
|         // @todo: Work on calculate this height better.
 | ||||
|         if (!this.maxHeight) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Remove max-height (if any) to calculate the real height.
 | ||||
|         const initialMaxHeight = this.element.style.maxHeight; | ||||
|         this.element.style.maxHeight = ''; | ||||
| 
 | ||||
|         const height = this.getElementHeight(this.element); | ||||
| 
 | ||||
|         // Restore the max height now.
 | ||||
|         this.element.style.maxHeight = initialMaxHeight; | ||||
| 
 | ||||
|         // If cannot calculate height, shorten always.
 | ||||
|         if (!height || height > this.maxHeight) { | ||||
|             if (!this.showMoreDisplayed) { | ||||
|                 this.displayShowMore(); | ||||
|             } | ||||
|         } else if (this.showMoreDisplayed) { | ||||
|             this.hideShowMore(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Display the "Show more" in the element. | ||||
|      */ | ||||
|     protected displayShowMore(): void { | ||||
|         const expandInFullview = CoreUtils.instance.isTrueOrOne(this.fullOnClick) || false; | ||||
|         const showMoreDiv = document.createElement('div'); | ||||
| 
 | ||||
|         showMoreDiv.classList.add('core-show-more'); | ||||
|         showMoreDiv.innerHTML = Translate.instance.instant('core.showmore'); | ||||
|         this.element.appendChild(showMoreDiv); | ||||
| 
 | ||||
|         if (expandInFullview) { | ||||
|             this.element.classList.add('core-expand-in-fullview'); | ||||
|         } | ||||
|         this.element.classList.add('core-text-formatted'); | ||||
|         this.element.classList.add('core-shortened'); | ||||
|         this.element.style.maxHeight = this.maxHeight + 'px'; | ||||
| 
 | ||||
|         this.showMoreDisplayed = true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Listener to call when the element is clicked. | ||||
|      * | ||||
|      * @param e Click event. | ||||
|      */ | ||||
|     protected elementClicked(e: MouseEvent): void { | ||||
|         if (e.defaultPrevented) { | ||||
|             // Ignore it if the event was prevented by some other listener.
 | ||||
|             return; | ||||
|         } | ||||
|         if (!this.text) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const expandInFullview = CoreUtils.instance.isTrueOrOne(this.fullOnClick) || false; | ||||
| 
 | ||||
|         if (!expandInFullview && !this.showMoreDisplayed) { | ||||
|             // Nothing to do on click, just stop.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         e.preventDefault(); | ||||
|         e.stopPropagation(); | ||||
| 
 | ||||
|         if (!expandInFullview) { | ||||
|             // Change class.
 | ||||
|             this.element.classList.toggle('core-shortened'); | ||||
| 
 | ||||
|             return; | ||||
|         } else { | ||||
|             // Open a new state with the contents.
 | ||||
|             const filter = typeof this.filter != 'undefined' ? CoreUtils.instance.isTrueOrOne(this.filter) : undefined; | ||||
| 
 | ||||
|             CoreTextUtils.instance.viewText( | ||||
|                 this.fullTitle || Translate.instance.instant('core.description'), | ||||
|                 this.text, | ||||
|                 { | ||||
|                     component: this.component, | ||||
|                     componentId: this.componentId, | ||||
|                     filter: filter, | ||||
|                     contextLevel: this.contextLevel, | ||||
|                     instanceId: this.contextInstanceId, | ||||
|                     courseId: this.courseId, | ||||
|                 }, | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Finish the rendering, displaying the element again and calling afterRender. | ||||
|      */ | ||||
|     protected finishRender(): void { | ||||
|         // Show the element again.
 | ||||
|         this.element.classList.remove('opacity-hide'); | ||||
|         // Emit the afterRender output.
 | ||||
|         this.afterRender.emit(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Format contents and render. | ||||
|      */ | ||||
|     protected async formatAndRenderContents(): Promise<void> { | ||||
|         if (!this.text) { | ||||
|             this.element.innerHTML = ''; // Remove current contents.
 | ||||
|             this.finishRender(); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // In AOT the inputs and ng-reflect aren't in the DOM sometimes. Add them so styles are applied.
 | ||||
|         if (this.maxHeight && !this.element.getAttribute('maxHeight')) { | ||||
|             this.element.setAttribute('maxHeight', String(this.maxHeight)); | ||||
|         } | ||||
|         if (!this.element.getAttribute('singleLine')) { | ||||
|             this.element.setAttribute('singleLine', String(CoreUtils.instance.isTrueOrOne(this.singleLine))); | ||||
|         } | ||||
| 
 | ||||
|         this.text = this.text ? this.text.trim() : ''; | ||||
| 
 | ||||
|         const result = await this.formatContents(); | ||||
| 
 | ||||
|         // Disable media adapt to correctly calculate the height.
 | ||||
|         this.element.classList.add('core-disable-media-adapt'); | ||||
| 
 | ||||
|         this.element.innerHTML = ''; // Remove current contents.
 | ||||
|         if (this.maxHeight && result.div.innerHTML != '' && | ||||
|                 (this.fullOnClick || (window.innerWidth < 576 || window.innerHeight < 576))) { // Don't collapse in big screens.
 | ||||
| 
 | ||||
|             // Move the children to the current element to be able to calculate the height.
 | ||||
|             CoreDomUtils.instance.moveChildren(result.div, this.element); | ||||
| 
 | ||||
|             // Calculate the height now.
 | ||||
|             this.calculateHeight(); | ||||
| 
 | ||||
|             // Add magnifying glasses to images.
 | ||||
|             this.addMagnifyingGlasses(); | ||||
| 
 | ||||
|             if (!this.loadingChangedListener) { | ||||
|                 // Recalculate the height if a parent core-loading displays the content.
 | ||||
|                 this.loadingChangedListener = | ||||
|                     CoreEvents.instance.on(CoreEventsProvider.CORE_LOADING_CHANGED, (data: CoreEventLoadingChangedData) => { | ||||
|                         if (data.loaded && CoreDomUtils.instance.closest(this.element.parentElement, '#' + data.uniqueId)) { | ||||
|                             // The format-text is inside the loading, re-calculate the height.
 | ||||
|                             this.calculateHeight(); | ||||
|                         } | ||||
|                     }); | ||||
|             } | ||||
|         } else { | ||||
|             CoreDomUtils.instance.moveChildren(result.div, this.element); | ||||
| 
 | ||||
|             // Add magnifying glasses to images.
 | ||||
|             this.addMagnifyingGlasses(); | ||||
|         } | ||||
| 
 | ||||
|         if (result.options.filter) { | ||||
|             // Let filters hnadle HTML. We do it here because we don't want them to block the render of the text.
 | ||||
|             // @todo
 | ||||
|         } | ||||
| 
 | ||||
|         this.element.classList.remove('core-disable-media-adapt'); | ||||
|         this.finishRender(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Apply formatText and set sub-directives. | ||||
|      * | ||||
|      * @return Promise resolved with a div element containing the code. | ||||
|      */ | ||||
|     protected async formatContents(): Promise<FormatContentsResult> { | ||||
|         // Retrieve the site since it might be needed later.
 | ||||
|         const site = await CoreUtils.instance.ignoreErrors(CoreSites.instance.getSite(this.siteId)); | ||||
| 
 | ||||
|         if (site && this.contextLevel == 'course' && this.contextInstanceId !== undefined && this.contextInstanceId <= 0) { | ||||
|             this.contextInstanceId = site.getSiteHomeId(); | ||||
|         } | ||||
| 
 | ||||
|         const filter = typeof this.filter == 'undefined' ? | ||||
|             !!(this.contextLevel && typeof this.contextInstanceId != 'undefined') : CoreUtils.instance.isTrueOrOne(this.filter); | ||||
| 
 | ||||
|         const options = { | ||||
|             clean: CoreUtils.instance.isTrueOrOne(this.clean), | ||||
|             singleLine: CoreUtils.instance.isTrueOrOne(this.singleLine), | ||||
|             highlight: this.highlight, | ||||
|             courseId: this.courseId, | ||||
|             wsNotFiltered: CoreUtils.instance.isTrueOrOne(this.wsNotFiltered), | ||||
|         }; | ||||
| 
 | ||||
|         let formatted: string; | ||||
| 
 | ||||
|         if (filter) { | ||||
|             // @todo
 | ||||
|             formatted = this.text!; | ||||
|         } else { | ||||
|             // @todo
 | ||||
|             formatted = this.text!; | ||||
|         } | ||||
| 
 | ||||
|         formatted = this.treatWindowOpen(formatted); | ||||
| 
 | ||||
|         const div = document.createElement('div'); | ||||
| 
 | ||||
|         div.innerHTML = formatted; | ||||
| 
 | ||||
|         this.treatHTMLElements(div, site); | ||||
| 
 | ||||
|         return { | ||||
|             div, | ||||
|             filters: [], | ||||
|             options, | ||||
|             siteId: site?.getId(), | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Treat HTML elements when formatting contents. | ||||
|      * | ||||
|      * @param div Div element. | ||||
|      * @param site Site instance. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async treatHTMLElements(div: HTMLElement, site?: CoreSite): Promise<void> { | ||||
|         const canTreatVimeo = site?.isVersionGreaterEqualThan(['3.3.4', '3.4']) || false; | ||||
|         const navCtrl = this.navCtrl; // @todo this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
 | ||||
| 
 | ||||
|         const images = Array.from(div.querySelectorAll('img')); | ||||
|         const anchors = Array.from(div.querySelectorAll('a')); | ||||
|         const audios = Array.from(div.querySelectorAll('audio')); | ||||
|         const videos = Array.from(div.querySelectorAll('video')); | ||||
|         const iframes = Array.from(div.querySelectorAll('iframe')); | ||||
|         const buttons = Array.from(div.querySelectorAll('.button')); | ||||
|         const elementsWithInlineStyles = Array.from(div.querySelectorAll('*[style]')); | ||||
|         const stopClicksElements = Array.from(div.querySelectorAll('button,input,select,textarea')); | ||||
|         const frames = Array.from(div.querySelectorAll(CoreIframeUtilsProvider.FRAME_TAGS.join(',').replace(/iframe,?/, ''))); | ||||
|         const svgImages = Array.from(div.querySelectorAll('image')); | ||||
| 
 | ||||
|         // Walk through the content to find the links and add our directive to it.
 | ||||
|         // Important: We need to look for links first because in 'img' we add new links without core-link.
 | ||||
|         anchors.forEach((anchor) => { | ||||
|             // Angular 2 doesn't let adding directives dynamically. Create the CoreLinkDirective manually.
 | ||||
|             // @todo
 | ||||
| 
 | ||||
|             this.addExternalContent(anchor); | ||||
|         }); | ||||
| 
 | ||||
|         const externalImages: CoreExternalContentDirective[] = []; | ||||
|         if (images && images.length > 0) { | ||||
|             // Walk through the content to find images, and add our directive.
 | ||||
|             images.forEach((img: HTMLElement) => { | ||||
|                 this.addMediaAdaptClass(img); | ||||
| 
 | ||||
|                 const externalImage = this.addExternalContent(img); | ||||
|                 if (!externalImage.invalid) { | ||||
|                     externalImages.push(externalImage); | ||||
|                 } | ||||
| 
 | ||||
|                 if (CoreUtils.instance.isTrueOrOne(this.adaptImg) && !img.classList.contains('icon')) { | ||||
|                     this.adaptImage(img); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         audios.forEach((audio) => { | ||||
|             this.treatMedia(audio); | ||||
|         }); | ||||
| 
 | ||||
|         videos.forEach((video) => { | ||||
|             this.treatMedia(video); | ||||
|         }); | ||||
| 
 | ||||
|         iframes.forEach((iframe) => { | ||||
|             this.treatIframe(iframe, site, canTreatVimeo, navCtrl); | ||||
|         }); | ||||
| 
 | ||||
|         svgImages.forEach((image) => { | ||||
|             this.addExternalContent(image); | ||||
|         }); | ||||
| 
 | ||||
|         // Handle buttons with inner links.
 | ||||
|         buttons.forEach((button: HTMLElement) => { | ||||
|             // Check if it has a link inside.
 | ||||
|             if (button.querySelector('a')) { | ||||
|                 button.classList.add('core-button-with-inner-link'); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         // Handle inline styles.
 | ||||
|         elementsWithInlineStyles.forEach((el: HTMLElement) => { | ||||
|             // Only add external content for tags that haven't been treated already.
 | ||||
|             if (el.tagName != 'A' && el.tagName != 'IMG' && el.tagName != 'AUDIO' && el.tagName != 'VIDEO' | ||||
|                     && el.tagName != 'SOURCE' && el.tagName != 'TRACK') { | ||||
|                 this.addExternalContent(el); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         // Stop propagating click events.
 | ||||
|         stopClicksElements.forEach((element: HTMLElement) => { | ||||
|             element.addEventListener('click', (e) => { | ||||
|                 e.stopPropagation(); | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         // Handle all kind of frames.
 | ||||
|         frames.forEach((frame: HTMLFrameElement | HTMLObjectElement | HTMLEmbedElement) => { | ||||
|             CoreIframeUtils.instance.treatFrame(frame, false, navCtrl); | ||||
|         }); | ||||
| 
 | ||||
|         CoreDomUtils.instance.handleBootstrapTooltips(div); | ||||
| 
 | ||||
|         if (externalImages.length) { | ||||
|             // Wait for images to load.
 | ||||
|             const promise = CoreUtils.instance.allPromises(externalImages.map((externalImage) => { | ||||
|                 if (externalImage.loaded) { | ||||
|                     // Image has already been loaded, no need to wait.
 | ||||
|                     return Promise.resolve(); | ||||
|                 } | ||||
| 
 | ||||
|                 return new Promise((resolve): void => { | ||||
|                     const subscription = externalImage.onLoad.subscribe(() => { | ||||
|                         subscription.unsubscribe(); | ||||
|                         resolve(); | ||||
|                     }); | ||||
|                 }); | ||||
|             })); | ||||
| 
 | ||||
|             // Automatically reject the promise after 5 seconds to prevent blocking the user forever.
 | ||||
|             await CoreUtils.instance.ignoreErrors(CoreUtils.instance.timeoutPromise(promise, 5000)); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns the element width in pixels. | ||||
|      * | ||||
|      * @param element Element to get width from. | ||||
|      * @return The width of the element in pixels. When 0 is returned it means the element is not visible. | ||||
|      */ | ||||
|     protected getElementWidth(element: HTMLElement): number { | ||||
|         let width = CoreDomUtils.instance.getElementWidth(element); | ||||
| 
 | ||||
|         if (!width) { | ||||
|             // All elements inside are floating or inline. Change display mode to allow calculate the width.
 | ||||
|             const parentWidth = element.parentElement ? | ||||
|                 CoreDomUtils.instance.getElementWidth(element.parentElement, true, false, false, true) : 0; | ||||
|             const previousDisplay = getComputedStyle(element, null).display; | ||||
| 
 | ||||
|             element.style.display = 'inline-block'; | ||||
| 
 | ||||
|             width = CoreDomUtils.instance.getElementWidth(element); | ||||
| 
 | ||||
|             // If width is incorrectly calculated use parent width instead.
 | ||||
|             if (parentWidth > 0 && (!width || width > parentWidth)) { | ||||
|                 width = parentWidth; | ||||
|             } | ||||
| 
 | ||||
|             element.style.display = previousDisplay; | ||||
|         } | ||||
| 
 | ||||
|         return width; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns the element height in pixels. | ||||
|      * | ||||
|      * @param elementAng Element to get height from. | ||||
|      * @return The height of the element in pixels. When 0 is returned it means the element is not visible. | ||||
|      */ | ||||
|     protected getElementHeight(element: HTMLElement): number { | ||||
|         return CoreDomUtils.instance.getElementHeight(element) || 0; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * "Hide" the "Show more" in the element if it's shown. | ||||
|      */ | ||||
|     protected hideShowMore(): void { | ||||
|         const showMoreDiv = this.element.querySelector('div.core-show-more'); | ||||
| 
 | ||||
|         if (showMoreDiv) { | ||||
|             showMoreDiv.remove(); | ||||
|         } | ||||
| 
 | ||||
|         this.element.classList.remove('core-expand-in-fullview'); | ||||
|         this.element.classList.remove('core-text-formatted'); | ||||
|         this.element.classList.remove('core-shortened'); | ||||
|         this.element.style.maxHeight = ''; | ||||
|         this.showMoreDisplayed = false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Add media adapt class and apply CoreExternalContentDirective to the media element and its sources and tracks. | ||||
|      * | ||||
|      * @param element Video or audio to treat. | ||||
|      */ | ||||
|     protected treatMedia(element: HTMLElement): void { | ||||
|         this.addMediaAdaptClass(element); | ||||
|         this.addExternalContent(element); | ||||
| 
 | ||||
|         const sources = Array.from(element.querySelectorAll('source')); | ||||
|         const tracks = Array.from(element.querySelectorAll('track')); | ||||
| 
 | ||||
|         sources.forEach((source) => { | ||||
|             source.setAttribute('target-src', source.getAttribute('src') || ''); | ||||
|             source.removeAttribute('src'); | ||||
|             this.addExternalContent(source); | ||||
|         }); | ||||
| 
 | ||||
|         tracks.forEach((track) => { | ||||
|             this.addExternalContent(track); | ||||
|         }); | ||||
| 
 | ||||
|         // Stop propagating click events.
 | ||||
|         element.addEventListener('click', (e) => { | ||||
|             e.stopPropagation(); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Add media adapt class and treat the iframe source. | ||||
|      * | ||||
|      * @param iframe Iframe to treat. | ||||
|      * @param site Site instance. | ||||
|      * @param canTreatVimeo Whether Vimeo videos can be treated in the site. | ||||
|      * @param navCtrl NavController to use. | ||||
|      */ | ||||
|     protected async treatIframe( | ||||
|         iframe: HTMLIFrameElement, | ||||
|         site: CoreSite | undefined, | ||||
|         canTreatVimeo: boolean, | ||||
|         navCtrl: NavController, | ||||
|     ): Promise<void> { | ||||
|         const src = iframe.src; | ||||
|         const currentSite = CoreSites.instance.getCurrentSite(); | ||||
| 
 | ||||
|         this.addMediaAdaptClass(iframe); | ||||
| 
 | ||||
|         if (currentSite?.containsUrl(src)) { | ||||
|             // URL points to current site, try to use auto-login.
 | ||||
|             const finalUrl = await currentSite.getAutoLoginUrl(src, false); | ||||
| 
 | ||||
|             iframe.src = finalUrl; | ||||
| 
 | ||||
|             CoreIframeUtils.instance.treatFrame(iframe, false, navCtrl); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (site && src && canTreatVimeo) { | ||||
|             // Check if it's a Vimeo video. If it is, use the wsplayer script instead to make restricted videos work.
 | ||||
|             const matches = iframe.src.match(/https?:\/\/player\.vimeo\.com\/video\/([0-9]+)/); | ||||
|             if (matches && matches[1]) { | ||||
|                 let newUrl = CoreTextUtils.instance.concatenatePaths(site.getURL(), '/media/player/vimeo/wsplayer.php?video=') + | ||||
|                     matches[1] + '&token=' + site.getToken(); | ||||
| 
 | ||||
|                 // Width and height are mandatory, we need to calculate them.
 | ||||
|                 let width; | ||||
|                 let height; | ||||
| 
 | ||||
|                 if (iframe.width) { | ||||
|                     width = iframe.width; | ||||
|                 } else { | ||||
|                     width = this.getElementWidth(iframe); | ||||
|                     if (!width) { | ||||
|                         width = window.innerWidth; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 if (iframe.height) { | ||||
|                     height = iframe.height; | ||||
|                 } else { | ||||
|                     height = this.getElementHeight(iframe); | ||||
|                     if (!height) { | ||||
|                         height = width; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 // Width and height parameters are required in 3.6 and older sites.
 | ||||
|                 if (site && !site.isVersionGreaterEqualThan('3.7')) { | ||||
|                     newUrl += '&width=' + width + '&height=' + height; | ||||
|                 } | ||||
|                 iframe.src = newUrl; | ||||
| 
 | ||||
|                 if (!iframe.width) { | ||||
|                     iframe.width = width; | ||||
|                 } | ||||
|                 if (!iframe.height) { | ||||
|                     iframe.height = height; | ||||
|                 } | ||||
| 
 | ||||
|                 // Do the iframe responsive.
 | ||||
|                 if (iframe.parentElement?.classList.contains('embed-responsive')) { | ||||
|                     iframe.addEventListener('load', () => { | ||||
|                         if (iframe.contentDocument) { | ||||
|                             const css = document.createElement('style'); | ||||
|                             css.setAttribute('type', 'text/css'); | ||||
|                             css.innerHTML = 'iframe {width: 100%;height: 100%;}'; | ||||
|                             iframe.contentDocument.head.appendChild(css); | ||||
|                         } | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         CoreIframeUtils.instance.treatFrame(iframe, false, navCtrl); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Convert window.open to window.openWindowSafely inside HTML tags. | ||||
|      * | ||||
|      * @param text Text to treat. | ||||
|      * @return Treated text. | ||||
|      */ | ||||
|     protected treatWindowOpen(text: string): string { | ||||
|         // Get HTML tags that include window.open. Script tags aren't executed so there's no need to treat them.
 | ||||
|         const matches = text.match(/<[^>]+window\.open\([^)]*\)[^>]*>/g); | ||||
| 
 | ||||
|         if (matches) { | ||||
|             matches.forEach((match) => { | ||||
|                 // Replace all the window.open inside the tag.
 | ||||
|                 const treated = match.replace(/window\.open\(/g, 'window.openWindowSafely('); | ||||
| 
 | ||||
|                 text = text.replace(match, treated); | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         return text; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| type FormatContentsResult = { | ||||
|     div: HTMLElement; | ||||
|     filters: any[]; | ||||
|     options: any; | ||||
|     siteId?: string; | ||||
| }; | ||||
							
								
								
									
										97
									
								
								src/app/directives/supress-events.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/app/directives/supress-events.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,97 @@ | ||||
| // (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.
 | ||||
| 
 | ||||
| // Based on http://roblouie.com/article/198/using-gestures-in-the-ionic-2-beta/
 | ||||
| 
 | ||||
| import { Directive, ElementRef, OnInit, Input, Output, EventEmitter } from '@angular/core'; | ||||
| 
 | ||||
| /** | ||||
|  * Directive to suppress all events on an element. This is useful to prevent keyboard closing when clicking this element. | ||||
|  * | ||||
|  * This directive is based on some code posted by johnthackstonanderson in | ||||
|  * https://github.com/ionic-team/ionic-plugin-keyboard/issues/81
 | ||||
|  * | ||||
|  * @description | ||||
|  * | ||||
|  * If nothing is supplied or string 'all', then all the default events will be suppressed. This is the recommended usage. | ||||
|  * | ||||
|  * If you only want to suppress a single event just pass the name of the event. If you want to suppress a set of events, | ||||
|  * pass an array with the names of the events to suppress. | ||||
|  * | ||||
|  * Example usage: | ||||
|  * | ||||
|  * <a ion-button [core-suppress-events] (onClick)="toggle($event)"> | ||||
|  */ | ||||
| @Directive({ | ||||
|     selector: '[core-suppress-events]', | ||||
| }) | ||||
| export class CoreSupressEventsDirective implements OnInit { | ||||
| 
 | ||||
|     @Input('core-suppress-events') suppressEvents?: string | string[]; | ||||
|     @Output() onClick = new EventEmitter(); // eslint-disable-line @angular-eslint/no-output-on-prefix
 | ||||
| 
 | ||||
|     protected element: HTMLElement; | ||||
| 
 | ||||
|     constructor(el: ElementRef) { | ||||
|         this.element = el.nativeElement; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize event listeners. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         let events: string[]; | ||||
| 
 | ||||
|         if (this.suppressEvents == 'all' || typeof this.suppressEvents == 'undefined' || this.suppressEvents === null) { | ||||
|             // Suppress all events.
 | ||||
|             events = ['click', 'mousedown', 'touchdown', 'touchmove', 'touchstart']; | ||||
| 
 | ||||
|         } else if (typeof this.suppressEvents == 'string') { | ||||
|             // It's a string, just suppress this event.
 | ||||
|             events = [this.suppressEvents]; | ||||
| 
 | ||||
|         } else if (Array.isArray(this.suppressEvents)) { | ||||
|             // Array supplied.
 | ||||
|             events = this.suppressEvents; | ||||
|         } else { | ||||
|             events = []; | ||||
|         } | ||||
| 
 | ||||
|         // Suppress the events.
 | ||||
|         for (const evName of events) { | ||||
|             this.element.addEventListener(evName, this.stopBubble.bind(this)); | ||||
|         } | ||||
| 
 | ||||
|         // Now listen to "click" events.
 | ||||
|         this.element.addEventListener('mouseup', (event) => { // Triggered in Android & iOS.
 | ||||
|             this.onClick.emit(event); | ||||
|         }); | ||||
| 
 | ||||
|         this.element.addEventListener('touchend', (event) => { // Triggered desktop & browser.
 | ||||
|             this.stopBubble(event); | ||||
|             this.onClick.emit(event); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Stop event default and propagation. | ||||
|      * | ||||
|      * @param event Event. | ||||
|      */ | ||||
|     protected stopBubble(event: Event): void { | ||||
|         event.preventDefault(); | ||||
|         event.stopPropagation(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -13,6 +13,7 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Injectable, NgZone, ApplicationRef } from '@angular/core'; | ||||
| import { Params } from '@angular/router'; | ||||
| import { Connection } from '@ionic-native/network/ngx'; | ||||
| 
 | ||||
| import { CoreDB } from '@services/db'; | ||||
| @ -224,7 +225,7 @@ export class CoreAppProvider { | ||||
|      * @param  storesConfig Config params to send the user to the right place. | ||||
|      * @return Store URL. | ||||
|      */ | ||||
|     getAppStoreUrl(storesConfig: CoreStoreConfig): string | null { | ||||
|     getAppStoreUrl(storesConfig: CoreStoreConfig): string | undefined { | ||||
|         if (this.isMac() && storesConfig.mac) { | ||||
|             return 'itms-apps://itunes.apple.com/app/' + storesConfig.mac; | ||||
|         } | ||||
| @ -253,7 +254,7 @@ export class CoreAppProvider { | ||||
|             return storesConfig.mobile; | ||||
|         } | ||||
| 
 | ||||
|         return storesConfig.default || null; | ||||
|         return storesConfig.default; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -563,11 +564,11 @@ export class CoreAppProvider { | ||||
|      * | ||||
|      * @return Object with siteid, state, params and timemodified. | ||||
|      */ | ||||
|     getRedirect<Params extends Record<string, unknown> = Record<string, unknown>>(): CoreRedirectData<Params> { | ||||
|     getRedirect(): CoreRedirectData { | ||||
|         if (localStorage?.getItem) { | ||||
|             try { | ||||
|                 const paramsJson = localStorage.getItem('CoreRedirectParams'); | ||||
|                 const data: CoreRedirectData<Params> = { | ||||
|                 const data: CoreRedirectData = { | ||||
|                     siteId: localStorage.getItem('CoreRedirectSiteId') || undefined, | ||||
|                     page: localStorage.getItem('CoreRedirectState')  || undefined, | ||||
|                     timemodified: parseInt(localStorage.getItem('CoreRedirectTime') || '0', 10), | ||||
| @ -593,7 +594,7 @@ export class CoreAppProvider { | ||||
|      * @param page Page to go. | ||||
|      * @param params Page params. | ||||
|      */ | ||||
|     storeRedirect(siteId: string, page: string, params: Record<string, unknown>): void { | ||||
|     storeRedirect(siteId: string, page: string, params: Params): void { | ||||
|         if (localStorage && localStorage.setItem) { | ||||
|             try { | ||||
|                 localStorage.setItem('CoreRedirectSiteId', siteId); | ||||
| @ -697,7 +698,7 @@ export class CoreApp extends makeSingleton(CoreAppProvider) {} | ||||
| /** | ||||
|  * Data stored for a redirect to another page/site. | ||||
|  */ | ||||
| export type CoreRedirectData<Params extends Record<string, unknown>> = { | ||||
| export type CoreRedirectData = { | ||||
|     /** | ||||
|      * ID of the site to load. | ||||
|      */ | ||||
|  | ||||
| @ -97,18 +97,17 @@ export class CoreCronDelegate { | ||||
|      * @param siteId Site ID. If not defined, all sites. | ||||
|      * @return Promise resolved if handler is executed successfully, rejected otherwise. | ||||
|      */ | ||||
|     protected checkAndExecuteHandler(name: string, force?: boolean, siteId?: string): Promise<void> { | ||||
|     protected async checkAndExecuteHandler(name: string, force?: boolean, siteId?: string): Promise<void> { | ||||
|         if (!this.handlers[name] || !this.handlers[name].execute) { | ||||
|             // Invalid handler.
 | ||||
|             const message = `Cannot execute handler because is invalid: ${name}`; | ||||
|             this.logger.debug(message); | ||||
| 
 | ||||
|             return Promise.reject(new CoreError(message)); | ||||
|             throw new CoreError(message); | ||||
|         } | ||||
| 
 | ||||
|         const usesNetwork = this.handlerUsesNetwork(name); | ||||
|         const isSync = !force && this.isHandlerSync(name); | ||||
|         let promise; | ||||
| 
 | ||||
|         if (usesNetwork && !CoreApp.instance.isOnline()) { | ||||
|             // Offline, stop executing.
 | ||||
| @ -116,47 +115,46 @@ export class CoreCronDelegate { | ||||
|             this.logger.debug(message); | ||||
|             this.stopHandler(name); | ||||
| 
 | ||||
|             return Promise.reject(new CoreError(message)); | ||||
|             throw new CoreError(message); | ||||
|         } | ||||
| 
 | ||||
|         if (isSync) { | ||||
|             // Check network connection.
 | ||||
|             promise = CoreConfig.instance.get(CoreConstants.SETTINGS_SYNC_ONLY_ON_WIFI, false) | ||||
|                 .then((syncOnlyOnWifi) => !syncOnlyOnWifi || CoreApp.instance.isWifi()); | ||||
|         } else { | ||||
|             promise = Promise.resolve(true); | ||||
|         } | ||||
|             const syncOnlyOnWifi = await CoreConfig.instance.get(CoreConstants.SETTINGS_SYNC_ONLY_ON_WIFI, false); | ||||
| 
 | ||||
|         return promise.then((execute: boolean) => { | ||||
|             if (!execute) { | ||||
|             if (syncOnlyOnWifi && !CoreApp.instance.isWifi()) { | ||||
|                 // Cannot execute in this network connection, retry soon.
 | ||||
|                 const message = `Cannot execute handler because device is using limited connection: ${name}`; | ||||
|                 this.logger.debug(message); | ||||
|                 this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); | ||||
| 
 | ||||
|                 return Promise.reject(new CoreError(message)); | ||||
|                 throw new CoreError(message); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Add the execution to the queue.
 | ||||
|         this.queuePromise = CoreUtils.instance.ignoreErrors(this.queuePromise).then(async () => { | ||||
|             try { | ||||
|                 await this.executeHandler(name, force, siteId); | ||||
| 
 | ||||
|             // Add the execution to the queue.
 | ||||
|             this.queuePromise = this.queuePromise.catch(() => { | ||||
|                 // Ignore errors in previous handlers.
 | ||||
|             }).then(() => this.executeHandler(name, force, siteId).then(() => { | ||||
|                 this.logger.debug(`Execution of handler '${name}' was a success.`); | ||||
| 
 | ||||
|                 return this.setHandlerLastExecutionTime(name, Date.now()).then(() => { | ||||
|                     this.scheduleNextExecution(name); | ||||
|                 }); | ||||
|             }, (error) => { | ||||
|                 await CoreUtils.instance.ignoreErrors(this.setHandlerLastExecutionTime(name, Date.now())); | ||||
| 
 | ||||
|                 this.scheduleNextExecution(name); | ||||
| 
 | ||||
|                 return; | ||||
|             } catch (error) { | ||||
|                 // Handler call failed. Retry soon.
 | ||||
|                 const message = `Execution of handler '${name}' failed.`; | ||||
|                 this.logger.error(message, error); | ||||
|                 this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); | ||||
| 
 | ||||
|                 return Promise.reject(new CoreError(message)); | ||||
|             })); | ||||
| 
 | ||||
|             return this.queuePromise; | ||||
|                 throw new CoreError(message); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         return this.queuePromise; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -172,7 +170,7 @@ export class CoreCronDelegate { | ||||
|             this.logger.debug('Executing handler: ' + name); | ||||
| 
 | ||||
|             // Wrap the call in Promise.resolve to make sure it's a promise.
 | ||||
|             Promise.resolve(this.handlers[name].execute(siteId, force)).then(resolve).catch(reject).finally(() => { | ||||
|             Promise.resolve(this.handlers[name].execute!(siteId, force)).then(resolve).catch(reject).finally(() => { | ||||
|                 clearTimeout(cancelTimeout); | ||||
|             }); | ||||
| 
 | ||||
| @ -192,7 +190,7 @@ export class CoreCronDelegate { | ||||
|      * @return Promise resolved if all handlers are executed successfully, rejected otherwise. | ||||
|      */ | ||||
|     async forceSyncExecution(siteId?: string): Promise<void> { | ||||
|         const promises = []; | ||||
|         const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|         for (const name in this.handlers) { | ||||
|             if (this.isHandlerManualSync(name)) { | ||||
| @ -208,11 +206,11 @@ export class CoreCronDelegate { | ||||
|      * Force execution of a cron tasks without waiting for the scheduled time. | ||||
|      * Please notice that some tasks may not be executed depending on the network connection and sync settings. | ||||
|      * | ||||
|      * @param name If provided, the name of the handler. | ||||
|      * @param name Name of the handler. | ||||
|      * @param siteId Site ID. If not defined, all sites. | ||||
|      * @return Promise resolved if handler has been executed successfully, rejected otherwise. | ||||
|      */ | ||||
|     forceCronHandlerExecution(name?: string, siteId?: string): Promise<void> { | ||||
|     forceCronHandlerExecution(name: string, siteId?: string): Promise<void> { | ||||
|         const handler = this.handlers[name]; | ||||
| 
 | ||||
|         // Mark the handler as running (it might be running already).
 | ||||
| @ -240,7 +238,7 @@ export class CoreCronDelegate { | ||||
| 
 | ||||
|         // Don't allow intervals lower than the minimum.
 | ||||
|         const minInterval = CoreApp.instance.isDesktop() ? CoreCronDelegate.DESKTOP_MIN_INTERVAL : CoreCronDelegate.MIN_INTERVAL; | ||||
|         const handlerInterval = this.handlers[name].getInterval(); | ||||
|         const handlerInterval = this.handlers[name].getInterval!(); | ||||
| 
 | ||||
|         if (!handlerInterval) { | ||||
|             return CoreCronDelegate.DEFAULT_INTERVAL; | ||||
| @ -288,12 +286,12 @@ export class CoreCronDelegate { | ||||
|      * @return True if handler uses network or not defined, false otherwise. | ||||
|      */ | ||||
|     protected handlerUsesNetwork(name: string): boolean { | ||||
|         if (!this.handlers[name] || !this.handlers[name].usesNetwork) { | ||||
|         if (!this.handlers[name] || this.handlers[name].usesNetwork) { | ||||
|             // Invalid, return default.
 | ||||
|             return true; | ||||
|         } | ||||
| 
 | ||||
|         return this.handlers[name].usesNetwork(); | ||||
|         return this.handlers[name].usesNetwork!(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -338,7 +336,7 @@ export class CoreCronDelegate { | ||||
|             return this.isHandlerSync(name); | ||||
|         } | ||||
| 
 | ||||
|         return this.handlers[name].canManualSync(); | ||||
|         return this.handlers[name].canManualSync!(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -353,7 +351,7 @@ export class CoreCronDelegate { | ||||
|             return true; | ||||
|         } | ||||
| 
 | ||||
|         return this.handlers[name].isSync(); | ||||
|         return this.handlers[name].isSync!(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -385,10 +383,10 @@ export class CoreCronDelegate { | ||||
|      * Schedule a next execution for a handler. | ||||
|      * | ||||
|      * @param name Name of the handler. | ||||
|      * @param time Time to the next execution. If not supplied it will be calculated using the last execution and | ||||
|      *             the handler's interval. This param should be used only if it's really necessary. | ||||
|      * @param timeToNextExecution Time (in milliseconds). If not supplied it will be calculated. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected scheduleNextExecution(name: string, time?: number): void { | ||||
|     protected async scheduleNextExecution(name: string, timeToNextExecution?: number): Promise<void> { | ||||
|         if (!this.handlers[name]) { | ||||
|             // Invalid handler.
 | ||||
|             return; | ||||
| @ -398,33 +396,24 @@ export class CoreCronDelegate { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         let promise; | ||||
| 
 | ||||
|         if (time) { | ||||
|             promise = Promise.resolve(time); | ||||
|         } else { | ||||
|         if (!timeToNextExecution) { | ||||
|             // Get last execution time to check when do we need to execute it.
 | ||||
|             promise = this.getHandlerLastExecutionTime(name).then((lastExecution) => { | ||||
|                 const interval = this.getHandlerInterval(name); | ||||
|                 const nextExecution = lastExecution + interval; | ||||
|             const lastExecution = await this.getHandlerLastExecutionTime(name); | ||||
| 
 | ||||
|                 return nextExecution - Date.now(); | ||||
|             }); | ||||
|             const interval = this.getHandlerInterval(name); | ||||
| 
 | ||||
|             timeToNextExecution = lastExecution + interval - Date.now(); | ||||
|         } | ||||
| 
 | ||||
|         promise.then((nextExecution) => { | ||||
|             this.logger.debug(`Scheduling next execution of handler '${name}' in '${nextExecution}' ms`); | ||||
|             if (nextExecution < 0) { | ||||
|                 nextExecution = 0; // Big negative numbers aren't executed immediately.
 | ||||
|             } | ||||
|         this.logger.debug(`Scheduling next execution of handler '${name}' in '${timeToNextExecution}' ms`); | ||||
|         if (timeToNextExecution < 0) { | ||||
|             timeToNextExecution = 0; // Big negative numbers aren't executed immediately.
 | ||||
|         } | ||||
| 
 | ||||
|             this.handlers[name].timeout = window.setTimeout(() => { | ||||
|                 delete this.handlers[name].timeout; | ||||
|                 this.checkAndExecuteHandler(name).catch(() => { | ||||
|                     // Ignore errors.
 | ||||
|                 }); | ||||
|             }, nextExecution); | ||||
|         }); | ||||
|         this.handlers[name].timeout = window.setTimeout(() => { | ||||
|             delete this.handlers[name].timeout; | ||||
|             CoreUtils.instance.ignoreErrors(this.checkAndExecuteHandler(name)); | ||||
|         }, timeToNextExecution); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -13,6 +13,7 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { Params } from '@angular/router'; | ||||
| import { Subject } from 'rxjs'; | ||||
| 
 | ||||
| import { CoreLogger } from '@singletons/logger'; | ||||
| @ -199,3 +200,20 @@ export class CoreEventsProvider { | ||||
| } | ||||
| 
 | ||||
| export class CoreEvents extends makeSingleton(CoreEventsProvider) {} | ||||
| 
 | ||||
| /** | ||||
|  * Data passed to SESSION_EXPIRED event. | ||||
|  */ | ||||
| export type CoreEventSessionExpiredData = { | ||||
|     pageName?: string; | ||||
|     params?: Params; | ||||
|     siteId?: string; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Data passed to CORE_LOADING_CHANGED event. | ||||
|  */ | ||||
| export type CoreEventLoadingChangedData = { | ||||
|     loaded: boolean; | ||||
|     uniqueId: string; | ||||
| }; | ||||
|  | ||||
| @ -44,11 +44,17 @@ export class CoreFileHelperProvider { | ||||
|      * @param siteId The site ID. If not defined, current site. | ||||
|      * @return Resolved on success. | ||||
|      */ | ||||
|     async downloadAndOpenFile(file: CoreWSExternalFile, component: string, componentId: string | number, state?: string, | ||||
|             onProgress?: CoreFileHelperOnProgress, siteId?: string): Promise<void> { | ||||
|     async downloadAndOpenFile( | ||||
|         file: CoreWSExternalFile, | ||||
|         component: string, | ||||
|         componentId: string | number, | ||||
|         state?: string, | ||||
|         onProgress?: CoreFileHelperOnProgress, | ||||
|         siteId?: string, | ||||
|     ): Promise<void> { | ||||
|         siteId = siteId || CoreSites.instance.getCurrentSiteId(); | ||||
| 
 | ||||
|         const fileUrl = this.getFileUrl(file); | ||||
|         const fileUrl = file.fileurl; | ||||
|         const timemodified = this.getFileTimemodified(file); | ||||
| 
 | ||||
|         if (!this.isOpenableInApp(file)) { | ||||
| @ -111,70 +117,76 @@ export class CoreFileHelperProvider { | ||||
|      * @param siteId The site ID. If not defined, current site. | ||||
|      * @return Resolved with the URL to use on success. | ||||
|      */ | ||||
|     protected downloadFileIfNeeded(file: CoreWSExternalFile, fileUrl: string, component?: string, componentId?: string | number, | ||||
|             timemodified?: number, state?: string, onProgress?: CoreFileHelperOnProgress, siteId?: string): Promise<string> { | ||||
|     protected async downloadFileIfNeeded( | ||||
|         file: CoreWSExternalFile, | ||||
|         fileUrl: string, | ||||
|         component?: string, | ||||
|         componentId?: string | number, | ||||
|         timemodified?: number, | ||||
|         state?: string, | ||||
|         onProgress?: CoreFileHelperOnProgress, | ||||
|         siteId?: string, | ||||
|     ): Promise<string> { | ||||
|         siteId = siteId || CoreSites.instance.getCurrentSiteId(); | ||||
| 
 | ||||
|         return CoreSites.instance.getSite(siteId).then((site) => site.checkAndFixPluginfileURL(fileUrl)).then((fixedUrl) => { | ||||
|             if (CoreFile.instance.isAvailable()) { | ||||
|                 let promise; | ||||
|                 if (state) { | ||||
|                     promise = Promise.resolve(state); | ||||
|                 } else { | ||||
|                     // Calculate the state.
 | ||||
|                     promise = CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified); | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
|         const fixedUrl = await site.checkAndFixPluginfileURL(fileUrl); | ||||
| 
 | ||||
|         if (!CoreFile.instance.isAvailable()) { | ||||
|             // Use the online URL.
 | ||||
|             return fixedUrl; | ||||
|         } | ||||
| 
 | ||||
|         if (!state) { | ||||
|             // Calculate the state.
 | ||||
|             state = await CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified); | ||||
|         } | ||||
| 
 | ||||
|         // The file system is available.
 | ||||
|         const isWifi = CoreApp.instance.isWifi(); | ||||
|         const isOnline = CoreApp.instance.isOnline(); | ||||
| 
 | ||||
|         if (state == CoreConstants.DOWNLOADED) { | ||||
|             // File is downloaded, get the local file URL.
 | ||||
|             return CoreFilepool.instance.getUrlByUrl(siteId, fileUrl, component, componentId, timemodified, false, false, file); | ||||
|         } else { | ||||
|             if (!isOnline && !this.isStateDownloaded(state)) { | ||||
|                 // Not downloaded and user is offline, reject.
 | ||||
|                 throw new CoreError(Translate.instance.instant('core.networkerrormsg')); | ||||
|             } | ||||
| 
 | ||||
|             if (onProgress) { | ||||
|                 // This call can take a while. Send a fake event to notify that we're doing some calculations.
 | ||||
|                 onProgress({ calculating: true }); | ||||
|             } | ||||
| 
 | ||||
|             try { | ||||
|                 await CoreFilepool.instance.shouldDownloadBeforeOpen(fixedUrl, file.filesize || 0); | ||||
|             } catch (error) { | ||||
|                 // Start the download if in wifi, but return the URL right away so the file is opened.
 | ||||
|                 if (isWifi) { | ||||
|                     this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); | ||||
|                 } | ||||
| 
 | ||||
|                 return promise.then((state) => { | ||||
|                     // The file system is available.
 | ||||
|                     const isWifi = CoreApp.instance.isWifi(); | ||||
|                     const isOnline = CoreApp.instance.isOnline(); | ||||
|                 if (!this.isStateDownloaded(state) || isOnline) { | ||||
|                     // Not downloaded or online, return the online URL.
 | ||||
|                     return fixedUrl; | ||||
|                 } else { | ||||
|                     // Outdated but offline, so we return the local URL.
 | ||||
|                     return CoreFilepool.instance.getUrlByUrl( | ||||
|                         siteId, fileUrl, component, componentId, timemodified, false, false, file); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|                     if (state == CoreConstants.DOWNLOADED) { | ||||
|                         // File is downloaded, get the local file URL.
 | ||||
|                         return CoreFilepool.instance.getUrlByUrl( | ||||
|                             siteId, fileUrl, component, componentId, timemodified, false, false, file); | ||||
|                     } else { | ||||
|                         if (!isOnline && !this.isStateDownloaded(state)) { | ||||
|                             // Not downloaded and user is offline, reject.
 | ||||
|                             return Promise.reject(new CoreError(Translate.instance.instant('core.networkerrormsg'))); | ||||
|                         } | ||||
| 
 | ||||
|                         if (onProgress) { | ||||
|                             // This call can take a while. Send a fake event to notify that we're doing some calculations.
 | ||||
|                             onProgress({ calculating: true }); | ||||
|                         } | ||||
| 
 | ||||
|                         return CoreFilepool.instance.shouldDownloadBeforeOpen(fixedUrl, file.filesize).then(() => { | ||||
|                             if (state == CoreConstants.DOWNLOADING) { | ||||
|                                 // It's already downloading, stop.
 | ||||
|                                 return; | ||||
|                             } | ||||
| 
 | ||||
|                             // Download and then return the local URL.
 | ||||
|                             return this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); | ||||
|                         }, () => { | ||||
|                             // Start the download if in wifi, but return the URL right away so the file is opened.
 | ||||
|                             if (isWifi) { | ||||
|                                 this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); | ||||
|                             } | ||||
| 
 | ||||
|                             if (!this.isStateDownloaded(state) || isOnline) { | ||||
|                                 // Not downloaded or online, return the online URL.
 | ||||
|                                 return fixedUrl; | ||||
|                             } else { | ||||
|                                 // Outdated but offline, so we return the local URL.
 | ||||
|                                 return CoreFilepool.instance.getUrlByUrl( | ||||
|                                     siteId, fileUrl, component, componentId, timemodified, false, false, file); | ||||
|                             } | ||||
|                         }); | ||||
|                     } | ||||
|                 }); | ||||
|             } else { | ||||
|                 // Use the online URL.
 | ||||
|             // Download the file first.
 | ||||
|             if (state == CoreConstants.DOWNLOADING) { | ||||
|                 // It's already downloading, stop.
 | ||||
|                 return fixedUrl; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|             // Download and then return the local URL.
 | ||||
|             return this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -189,29 +201,37 @@ export class CoreFileHelperProvider { | ||||
|      * @param siteId The site ID. If not defined, current site. | ||||
|      * @return Resolved with internal URL on success, rejected otherwise. | ||||
|      */ | ||||
|     downloadFile(fileUrl: string, component?: string, componentId?: string | number, timemodified?: number, | ||||
|         onProgress?: (event: ProgressEvent) => void, file?: CoreWSExternalFile, siteId?: string): Promise<string> { | ||||
|     async downloadFile( | ||||
|         fileUrl: string, | ||||
|         component?: string, | ||||
|         componentId?: string | number, | ||||
|         timemodified?: number, | ||||
|         onProgress?: (event: ProgressEvent) => void, | ||||
|         file?: CoreWSExternalFile, | ||||
|         siteId?: string, | ||||
|     ): Promise<string> { | ||||
|         siteId = siteId || CoreSites.instance.getCurrentSiteId(); | ||||
| 
 | ||||
|         // Get the site and check if it can download files.
 | ||||
|         return CoreSites.instance.getSite(siteId).then((site) => { | ||||
|             if (!site.canDownloadFiles()) { | ||||
|                 return Promise.reject(new CoreError(Translate.instance.instant('core.cannotdownloadfiles'))); | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
| 
 | ||||
|         if (!site.canDownloadFiles()) { | ||||
|             throw new CoreError(Translate.instance.instant('core.cannotdownloadfiles')); | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             return await CoreFilepool.instance.downloadUrl(siteId, fileUrl, false, component, componentId, timemodified, | ||||
|                 onProgress, undefined, file); | ||||
|         } catch (error) { | ||||
|             // Download failed, check the state again to see if the file was downloaded before.
 | ||||
|             const state = await CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified); | ||||
| 
 | ||||
|             if (this.isStateDownloaded(state)) { | ||||
|                 return CoreFilepool.instance.getInternalUrlByUrl(siteId, fileUrl); | ||||
|             } else { | ||||
|                 throw error; | ||||
|             } | ||||
| 
 | ||||
|             return CoreFilepool.instance.downloadUrl(siteId, fileUrl, false, component, componentId, | ||||
|                 timemodified, onProgress, undefined, file).catch((error) => | ||||
| 
 | ||||
|                 // Download failed, check the state again to see if the file was downloaded before.
 | ||||
|                 CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified).then((state) => { | ||||
|                     if (this.isStateDownloaded(state)) { | ||||
|                         return CoreFilepool.instance.getInternalUrlByUrl(siteId, fileUrl); | ||||
|                     } else { | ||||
|                         return Promise.reject(error); | ||||
|                     } | ||||
|                 }), | ||||
|             ); | ||||
|         }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -220,7 +240,7 @@ export class CoreFileHelperProvider { | ||||
|      * @param file The file. | ||||
|      * @deprecated since 3.9.5. Get directly the fileurl instead. | ||||
|      */ | ||||
|     getFileUrl(file: CoreWSExternalFile): string { | ||||
|     getFileUrl(file: CoreWSExternalFile): string | undefined { | ||||
|         return file.fileurl; | ||||
|     } | ||||
| 
 | ||||
| @ -337,11 +357,15 @@ export class CoreFileHelperProvider { | ||||
|      * @return bool. | ||||
|      */ | ||||
|     isOpenableInApp(file: {filename?: string; name?: string}): boolean { | ||||
|         const re = /(?:\.([^.]+))?$/; | ||||
|         const regex = /(?:\.([^.]+))?$/; | ||||
|         const regexResult = regex.exec(file.filename || file.name || ''); | ||||
| 
 | ||||
|         const ext = re.exec(file.filename || file.name)[1]; | ||||
|         if (!regexResult || !regexResult[1]) { | ||||
|             // Couldn't find the extension. Assume it's openable.
 | ||||
|             return true; | ||||
|         } | ||||
| 
 | ||||
|         return !this.isFileTypeExcludedInApp(ext); | ||||
|         return !this.isFileTypeExcludedInApp(regexResult[1]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -365,7 +389,7 @@ export class CoreFileHelperProvider { | ||||
|      */ | ||||
|     isFileTypeExcludedInApp(fileType: string): boolean { | ||||
|         const currentSite = CoreSites.instance.getCurrentSite(); | ||||
|         const fileTypeExcludeList = currentSite && <string> currentSite.getStoredConfig('tool_mobile_filetypeexclusionlist'); | ||||
|         const fileTypeExcludeList = currentSite?.getStoredConfig('tool_mobile_filetypeexclusionlist'); | ||||
| 
 | ||||
|         if (!fileTypeExcludeList) { | ||||
|             return false; | ||||
|  | ||||
| @ -85,7 +85,7 @@ export class CoreFileSessionProvider { | ||||
|      * @param id File area identifier. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      */ | ||||
|     protected initFileArea(component: string, id: string | number, siteId?: string): void { | ||||
|     protected initFileArea(component: string, id: string | number, siteId: string): void { | ||||
|         if (!this.files[siteId]) { | ||||
|             this.files[siteId] = {}; | ||||
|         } | ||||
|  | ||||
| @ -117,25 +117,25 @@ export class CoreFileProvider { | ||||
|      * | ||||
|      * @return Promise to be resolved when the initialization is finished. | ||||
|      */ | ||||
|     init(): Promise<void> { | ||||
|     async init(): Promise<void> { | ||||
|         if (this.initialized) { | ||||
|             return Promise.resolve(); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         return Platform.instance.ready().then(() => { | ||||
|             if (CoreApp.instance.isAndroid()) { | ||||
|                 this.basePath = File.instance.externalApplicationStorageDirectory || this.basePath; | ||||
|             } else if (CoreApp.instance.isIOS()) { | ||||
|                 this.basePath = File.instance.documentsDirectory || this.basePath; | ||||
|             } else if (!this.isAvailable() || this.basePath === '') { | ||||
|                 this.logger.error('Error getting device OS.'); | ||||
|         await Platform.instance.ready(); | ||||
| 
 | ||||
|                 return Promise.reject(new CoreError('Error getting device OS to initialize file system.')); | ||||
|             } | ||||
|         if (CoreApp.instance.isAndroid()) { | ||||
|             this.basePath = File.instance.externalApplicationStorageDirectory || this.basePath; | ||||
|         } else if (CoreApp.instance.isIOS()) { | ||||
|             this.basePath = File.instance.documentsDirectory || this.basePath; | ||||
|         } else if (!this.isAvailable() || this.basePath === '') { | ||||
|             this.logger.error('Error getting device OS.'); | ||||
| 
 | ||||
|             this.initialized = true; | ||||
|             this.logger.debug('FS initialized: ' + this.basePath); | ||||
|         }); | ||||
|             return Promise.reject(new CoreError('Error getting device OS to initialize file system.')); | ||||
|         } | ||||
| 
 | ||||
|         this.initialized = true; | ||||
|         this.logger.debug('FS initialized: ' + this.basePath); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -194,8 +194,12 @@ export class CoreFileProvider { | ||||
|      * @param base Base path to create the dir/file in. If not set, use basePath. | ||||
|      * @return Promise to be resolved when the dir/file is created. | ||||
|      */ | ||||
|     protected async create(isDirectory: boolean, path: string, failIfExists?: boolean, base?: string): | ||||
|             Promise<FileEntry | DirectoryEntry> { | ||||
|     protected async create( | ||||
|         isDirectory: boolean, | ||||
|         path: string, | ||||
|         failIfExists?: boolean, | ||||
|         base?: string, | ||||
|     ): Promise<FileEntry | DirectoryEntry> { | ||||
|         await this.init(); | ||||
| 
 | ||||
|         // Remove basePath if it's in the path.
 | ||||
| @ -340,17 +344,19 @@ export class CoreFileProvider { | ||||
|      * @return Promise to be resolved when the size is calculated. | ||||
|      */ | ||||
|     protected getSize(entry: DirectoryEntry | FileEntry): Promise<number> { | ||||
|         return new Promise((resolve, reject) => { | ||||
|         return new Promise<number>((resolve, reject) => { | ||||
|             if (this.isDirectoryEntry(entry)) { | ||||
|                 const directoryReader = entry.createReader(); | ||||
| 
 | ||||
|                 directoryReader.readEntries((entries: (DirectoryEntry | FileEntry)[]) => { | ||||
|                     const promises = []; | ||||
|                 directoryReader.readEntries(async (entries: (DirectoryEntry | FileEntry)[]) => { | ||||
|                     const promises: Promise<number>[] = []; | ||||
|                     for (let i = 0; i < entries.length; i++) { | ||||
|                         promises.push(this.getSize(entries[i])); | ||||
|                     } | ||||
| 
 | ||||
|                     Promise.all(promises).then((sizes) => { | ||||
|                     try { | ||||
|                         const sizes = await Promise.all(promises); | ||||
| 
 | ||||
|                         let directorySize = 0; | ||||
|                         for (let i = 0; i < sizes.length; i++) { | ||||
|                             const fileSize = Number(sizes[i]); | ||||
| @ -362,7 +368,9 @@ export class CoreFileProvider { | ||||
|                             directorySize += fileSize; | ||||
|                         } | ||||
|                         resolve(directorySize); | ||||
|                     }, reject); | ||||
|                     } catch (error) { | ||||
|                         reject(error); | ||||
|                     } | ||||
|                 }, reject); | ||||
|             } else { | ||||
|                 entry.file((file) => { | ||||
| @ -469,7 +477,7 @@ export class CoreFileProvider { | ||||
|                     const parsed = CoreTextUtils.instance.parseJSON(text, null); | ||||
| 
 | ||||
|                     if (parsed == null && text != null) { | ||||
|                         return Promise.reject(new CoreError('Error parsing JSON file: ' + path)); | ||||
|                         throw new CoreError('Error parsing JSON file: ' + path); | ||||
|                     } | ||||
| 
 | ||||
|                     return parsed; | ||||
| @ -494,7 +502,7 @@ export class CoreFileProvider { | ||||
|             const reader = new FileReader(); | ||||
| 
 | ||||
|             reader.onloadend = (event): void => { | ||||
|                 if (event.target.result !== undefined && event.target.result !== null) { | ||||
|                 if (event.target?.result !== undefined && event.target.result !== null) { | ||||
|                     if (format == CoreFileProvider.FORMATJSON) { | ||||
|                         // Convert to object.
 | ||||
|                         const parsed = CoreTextUtils.instance.parseJSON(<string> event.target.result, null); | ||||
| @ -507,7 +515,7 @@ export class CoreFileProvider { | ||||
|                     } else { | ||||
|                         resolve(event.target.result); | ||||
|                     } | ||||
|                 } else if (event.target.error !== undefined && event.target.error !== null) { | ||||
|                 } else if (event.target?.error !== undefined && event.target.error !== null) { | ||||
|                     reject(event.target.error); | ||||
|                 } else { | ||||
|                     reject({ code: null, message: 'READER_ONLOADEND_ERR' }); | ||||
| @ -550,25 +558,27 @@ export class CoreFileProvider { | ||||
|      * @param append Whether to append the data to the end of the file. | ||||
|      * @return Promise to be resolved when the file is written. | ||||
|      */ | ||||
|     writeFile(path: string, data: string | Blob, append?: boolean): Promise<FileEntry> { | ||||
|         return this.init().then(() => { | ||||
|             // Remove basePath if it's in the path.
 | ||||
|             path = this.removeStartingSlash(path.replace(this.basePath, '')); | ||||
|             this.logger.debug('Write file: ' + path); | ||||
|     async writeFile(path: string, data: string | Blob, append?: boolean): Promise<FileEntry> { | ||||
|         await this.init(); | ||||
| 
 | ||||
|             // Create file (and parent folders) to prevent errors.
 | ||||
|             return this.createFile(path).then((fileEntry) => { | ||||
|                 if (this.isHTMLAPI && !CoreApp.instance.isDesktop() && | ||||
|                     (typeof data == 'string' || data.toString() == '[object ArrayBuffer]')) { | ||||
|                     // We need to write Blobs.
 | ||||
|                     const type = CoreMimetypeUtils.instance.getMimeType(CoreMimetypeUtils.instance.getFileExtension(path)); | ||||
|                     data = new Blob([data], { type: type || 'text/plain' }); | ||||
|                 } | ||||
|         // Remove basePath if it's in the path.
 | ||||
|         path = this.removeStartingSlash(path.replace(this.basePath, '')); | ||||
|         this.logger.debug('Write file: ' + path); | ||||
| 
 | ||||
|                 return File.instance.writeFile(this.basePath, path, data, { replace: !append, append: !!append }) | ||||
|                     .then(() => fileEntry); | ||||
|             }); | ||||
|         }); | ||||
|         // Create file (and parent folders) to prevent errors.
 | ||||
|         const fileEntry = await this.createFile(path); | ||||
| 
 | ||||
|         if (this.isHTMLAPI && !CoreApp.instance.isDesktop() && | ||||
|                 (typeof data == 'string' || data.toString() == '[object ArrayBuffer]')) { | ||||
|             // We need to write Blobs.
 | ||||
|             const extension = CoreMimetypeUtils.instance.getFileExtension(path); | ||||
|             const type = extension ? CoreMimetypeUtils.instance.getMimeType(extension) : ''; | ||||
|             data = new Blob([data], { type: type || 'text/plain' }); | ||||
|         } | ||||
| 
 | ||||
|         await File.instance.writeFile(this.basePath, path, data, { replace: !append, append: !!append }); | ||||
| 
 | ||||
|         return fileEntry; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -583,8 +593,13 @@ export class CoreFileProvider { | ||||
|      * @param append Whether to append the data to the end of the file. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async writeFileDataInFile(file: Blob, path: string, onProgress?: CoreFileProgressFunction, offset: number = 0, | ||||
|         append?: boolean): Promise<FileEntry> { | ||||
|     async writeFileDataInFile( | ||||
|         file: Blob, | ||||
|         path: string, | ||||
|         onProgress?: CoreFileProgressFunction, | ||||
|         offset: number = 0, | ||||
|         append?: boolean, | ||||
|     ): Promise<FileEntry> { | ||||
|         offset = offset || 0; | ||||
| 
 | ||||
|         try { | ||||
| @ -675,16 +690,18 @@ export class CoreFileProvider { | ||||
|      * | ||||
|      * @return Promise to be resolved when the base path is retrieved. | ||||
|      */ | ||||
|     getBasePathToDownload(): Promise<string> { | ||||
|         return this.init().then(() => { | ||||
|             if (CoreApp.instance.isIOS()) { | ||||
|                 // In iOS we want the internal URL (cdvfile://localhost/persistent/...).
 | ||||
|                 return File.instance.resolveDirectoryUrl(this.basePath).then((dirEntry) => dirEntry.toInternalURL()); | ||||
|             } else { | ||||
|                 // In the other platforms we use the basePath as it is (file://...).
 | ||||
|                 return this.basePath; | ||||
|             } | ||||
|         }); | ||||
|     async getBasePathToDownload(): Promise<string> { | ||||
|         await this.init(); | ||||
| 
 | ||||
|         if (CoreApp.instance.isIOS()) { | ||||
|             // In iOS we want the internal URL (cdvfile://localhost/persistent/...).
 | ||||
|             const dirEntry = await File.instance.resolveDirectoryUrl(this.basePath); | ||||
| 
 | ||||
|             return dirEntry.toInternalURL(); | ||||
|         } else { | ||||
|             // In the other platforms we use the basePath as it is (file://...).
 | ||||
|             return this.basePath; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -773,18 +790,22 @@ export class CoreFileProvider { | ||||
|      *                      try to create it (slower). | ||||
|      * @return Promise resolved when the entry is copied. | ||||
|      */ | ||||
|     protected async copyOrMoveFileOrDir(from: string, to: string, isDir?: boolean, copy?: boolean, destDirExists?: boolean): | ||||
|             Promise<FileEntry | DirectoryEntry> { | ||||
|     protected async copyOrMoveFileOrDir( | ||||
|         from: string, | ||||
|         to: string, | ||||
|         isDir?: boolean, | ||||
|         copy?: boolean, | ||||
|         destDirExists?: boolean, | ||||
|     ): Promise<FileEntry | DirectoryEntry> { | ||||
|         const fileIsInAppFolder = this.isPathInAppFolder(from); | ||||
| 
 | ||||
|         if (!fileIsInAppFolder) { | ||||
|             return this.copyOrMoveExternalFile(from, to, copy); | ||||
|         } | ||||
| 
 | ||||
|         const moveCopyFn: (path: string, dirName: string, newPath: string, newDirName: string) => | ||||
|             Promise<FileEntry | DirectoryEntry> = copy ? | ||||
|                 (isDir ? File.instance.copyDir.bind(File.instance) : File.instance.copyFile.bind(File.instance)) : | ||||
|                 (isDir ? File.instance.moveDir.bind(File.instance) : File.instance.moveFile.bind(File.instance)); | ||||
|         const moveCopyFn: MoveCopyFunction = copy ? | ||||
|             (isDir ? File.instance.copyDir.bind(File.instance) : File.instance.copyFile.bind(File.instance)) : | ||||
|             (isDir ? File.instance.moveDir.bind(File.instance) : File.instance.moveFile.bind(File.instance)); | ||||
| 
 | ||||
|         await this.init(); | ||||
| 
 | ||||
| @ -880,6 +901,8 @@ export class CoreFileProvider { | ||||
|         if (path.indexOf(this.basePath) > -1) { | ||||
|             return path.replace(this.basePath, ''); | ||||
|         } | ||||
| 
 | ||||
|         return path; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -892,33 +915,31 @@ export class CoreFileProvider { | ||||
|      * @param recreateDir Delete the dest directory before unzipping. Defaults to true. | ||||
|      * @return Promise resolved when the file is unzipped. | ||||
|      */ | ||||
|     unzipFile(path: string, destFolder?: string, onProgress?: (progress: ProgressEvent) => void, recreateDir: boolean = true): | ||||
|             Promise<void> { | ||||
|     async unzipFile( | ||||
|         path: string, | ||||
|         destFolder?: string, | ||||
|         onProgress?: (progress: ProgressEvent) => void, | ||||
|         recreateDir: boolean = true, | ||||
|     ): Promise<void> { | ||||
|         // Get the source file.
 | ||||
|         let fileEntry: FileEntry; | ||||
|         const fileEntry = await this.getFile(path); | ||||
| 
 | ||||
|         return this.getFile(path).then((fe) => { | ||||
|             fileEntry = fe; | ||||
|         if (destFolder && recreateDir) { | ||||
|             // Make sure the dest dir doesn't exist already.
 | ||||
|             await CoreUtils.instance.ignoreErrors(this.removeDir(destFolder)); | ||||
| 
 | ||||
|             if (destFolder && recreateDir) { | ||||
|                 // Make sure the dest dir doesn't exist already.
 | ||||
|                 return this.removeDir(destFolder).catch(() => { | ||||
|                     // Ignore errors.
 | ||||
|                 }).then(() => | ||||
|                     // Now create the dir, otherwise if any of the ancestor dirs doesn't exist the unzip would fail.
 | ||||
|                     this.createDir(destFolder), | ||||
|                 ); | ||||
|             } | ||||
|         }).then(() => { | ||||
|             // If destFolder is not set, use same location as ZIP file. We need to use absolute paths (including basePath).
 | ||||
|             destFolder = this.addBasePathIfNeeded(destFolder || CoreMimetypeUtils.instance.removeExtension(path)); | ||||
|             // Now create the dir, otherwise if any of the ancestor dirs doesn't exist the unzip would fail.
 | ||||
|             await this.createDir(destFolder); | ||||
|         } | ||||
| 
 | ||||
|             return Zip.instance.unzip(fileEntry.toURL(), destFolder, onProgress); | ||||
|         }).then((result) => { | ||||
|             if (result == -1) { | ||||
|                 return Promise.reject(new CoreError('Unzip failed.')); | ||||
|             } | ||||
|         }); | ||||
|         // If destFolder is not set, use same location as ZIP file. We need to use absolute paths (including basePath).
 | ||||
|         destFolder = this.addBasePathIfNeeded(destFolder || CoreMimetypeUtils.instance.removeExtension(path)); | ||||
| 
 | ||||
|         const result = await Zip.instance.unzip(fileEntry.toURL(), destFolder, onProgress); | ||||
| 
 | ||||
|         if (result == -1) { | ||||
|             throw new CoreError('Unzip failed.'); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -999,22 +1020,22 @@ export class CoreFileProvider { | ||||
|      * @param copy True to copy, false to move. | ||||
|      * @return Promise resolved when the entry is copied/moved. | ||||
|      */ | ||||
|     protected copyOrMoveExternalFile(from: string, to: string, copy?: boolean): Promise<FileEntry> { | ||||
|     protected async copyOrMoveExternalFile(from: string, to: string, copy?: boolean): Promise<FileEntry> { | ||||
|         // Get the file to copy/move.
 | ||||
|         return this.getExternalFile(from).then((fileEntry) => { | ||||
|             // Create the destination dir if it doesn't exist.
 | ||||
|             const dirAndFile = this.getFileAndDirectoryFromPath(to); | ||||
|         const fileEntry = await this.getExternalFile(from); | ||||
| 
 | ||||
|             return this.createDir(dirAndFile.directory).then((dirEntry) => | ||||
|                 // Now copy/move the file.
 | ||||
|                 new Promise((resolve, reject): void => { | ||||
|                     if (copy) { | ||||
|                         fileEntry.copyTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); | ||||
|                     } else { | ||||
|                         fileEntry.moveTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); | ||||
|                     } | ||||
|                 }), | ||||
|             ); | ||||
|         // Create the destination dir if it doesn't exist.
 | ||||
|         const dirAndFile = this.getFileAndDirectoryFromPath(to); | ||||
| 
 | ||||
|         const dirEntry = await this.createDir(dirAndFile.directory); | ||||
| 
 | ||||
|         // Now copy/move the file.
 | ||||
|         return new Promise((resolve, reject): void => { | ||||
|             if (copy) { | ||||
|                 fileEntry.copyTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); | ||||
|             } else { | ||||
|                 fileEntry.moveTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| @ -1048,9 +1069,11 @@ export class CoreFileProvider { | ||||
|      * @param defaultExt Default extension to use if no extension found in the file. | ||||
|      * @return Promise resolved with the unique file name. | ||||
|      */ | ||||
|     getUniqueNameInFolder(dirPath: string, fileName: string, defaultExt?: string): Promise<string> { | ||||
|     async getUniqueNameInFolder(dirPath: string, fileName: string, defaultExt?: string): Promise<string> { | ||||
|         // Get existing files in the folder.
 | ||||
|         return this.getDirectoryContents(dirPath).then((entries) => { | ||||
|         try { | ||||
|             const entries = await this.getDirectoryContents(dirPath); | ||||
| 
 | ||||
|             const files = {}; | ||||
|             let num = 1; | ||||
|             let fileNameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(fileName); | ||||
| @ -1058,7 +1081,8 @@ export class CoreFileProvider { | ||||
| 
 | ||||
|             // Clean the file name.
 | ||||
|             fileNameWithoutExtension = CoreTextUtils.instance.removeSpecialCharactersForFiles( | ||||
|                 CoreTextUtils.instance.decodeURIComponent(fileNameWithoutExtension)); | ||||
|                 CoreTextUtils.instance.decodeURIComponent(fileNameWithoutExtension), | ||||
|             ); | ||||
| 
 | ||||
|             // Index the files by name.
 | ||||
|             entries.forEach((entry) => { | ||||
| @ -1086,10 +1110,10 @@ export class CoreFileProvider { | ||||
|                 // Ask the user what he wants to do.
 | ||||
|                 return newName; | ||||
|             } | ||||
|         }).catch(() => | ||||
|         } catch (error) { | ||||
|             // Folder doesn't exist, name is unique. Clean it and return it.
 | ||||
|             CoreTextUtils.instance.removeSpecialCharactersForFiles(CoreTextUtils.instance.decodeURIComponent(fileName)), | ||||
|         ); | ||||
|             return CoreTextUtils.instance.removeSpecialCharactersForFiles(CoreTextUtils.instance.decodeURIComponent(fileName)); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -1119,7 +1143,7 @@ export class CoreFileProvider { | ||||
|             } | ||||
| 
 | ||||
|             const filesMap: {[fullPath: string]: FileEntry} = {}; | ||||
|             const promises = []; | ||||
|             const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|             // Index the received files by fullPath and ignore the invalid ones.
 | ||||
|             files.forEach((file) => { | ||||
| @ -1219,3 +1243,5 @@ export class CoreFileProvider { | ||||
| } | ||||
| 
 | ||||
| export class CoreFile extends makeSingleton(CoreFileProvider) {} | ||||
| 
 | ||||
| type MoveCopyFunction = (path: string, dirName: string, newPath: string, newDirName: string) => Promise<FileEntry | DirectoryEntry>; | ||||
|  | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -106,7 +106,7 @@ export class CoreLocalNotificationsProvider { | ||||
|     protected cancelSubscription?: Subscription; | ||||
|     protected addSubscription?: Subscription; | ||||
|     protected updateSubscription?: Subscription; | ||||
|     protected queueRunner?: CoreQueueRunner; // Queue to decrease the number of concurrent calls to the plugin (see MOBILE-3477).
 | ||||
|     protected queueRunner: CoreQueueRunner; // Queue to decrease the number of concurrent calls to the plugin (see MOBILE-3477).
 | ||||
| 
 | ||||
|     constructor() { | ||||
|         this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider'); | ||||
| @ -116,46 +116,53 @@ export class CoreLocalNotificationsProvider { | ||||
|             // Ignore errors.
 | ||||
|         }); | ||||
| 
 | ||||
|         Platform.instance.ready().then(() => { | ||||
|             // Listen to events.
 | ||||
|             this.triggerSubscription = LocalNotifications.instance.on('trigger').subscribe((notification: ILocalNotification) => { | ||||
|                 this.trigger(notification); | ||||
|         this.init(); | ||||
|     } | ||||
| 
 | ||||
|                 this.handleEvent('trigger', notification); | ||||
|             }); | ||||
|     /** | ||||
|      * Init some properties. | ||||
|      */ | ||||
|     protected async init(): Promise<void> { | ||||
|         await Platform.instance.ready(); | ||||
| 
 | ||||
|             this.clickSubscription = LocalNotifications.instance.on('click').subscribe((notification: ILocalNotification) => { | ||||
|                 this.handleEvent('click', notification); | ||||
|             }); | ||||
|         // Listen to events.
 | ||||
|         this.triggerSubscription = LocalNotifications.instance.on('trigger').subscribe((notification: ILocalNotification) => { | ||||
|             this.trigger(notification); | ||||
| 
 | ||||
|             this.clearSubscription = LocalNotifications.instance.on('clear').subscribe((notification: ILocalNotification) => { | ||||
|                 this.handleEvent('clear', notification); | ||||
|             }); | ||||
|             this.handleEvent('trigger', notification); | ||||
|         }); | ||||
| 
 | ||||
|             this.cancelSubscription = LocalNotifications.instance.on('cancel').subscribe((notification: ILocalNotification) => { | ||||
|                 this.handleEvent('cancel', notification); | ||||
|             }); | ||||
|         this.clickSubscription = LocalNotifications.instance.on('click').subscribe((notification: ILocalNotification) => { | ||||
|             this.handleEvent('click', notification); | ||||
|         }); | ||||
| 
 | ||||
|             this.addSubscription = LocalNotifications.instance.on('schedule').subscribe((notification: ILocalNotification) => { | ||||
|                 this.handleEvent('schedule', notification); | ||||
|             }); | ||||
|         this.clearSubscription = LocalNotifications.instance.on('clear').subscribe((notification: ILocalNotification) => { | ||||
|             this.handleEvent('clear', notification); | ||||
|         }); | ||||
| 
 | ||||
|             this.updateSubscription = LocalNotifications.instance.on('update').subscribe((notification: ILocalNotification) => { | ||||
|                 this.handleEvent('update', notification); | ||||
|             }); | ||||
|         this.cancelSubscription = LocalNotifications.instance.on('cancel').subscribe((notification: ILocalNotification) => { | ||||
|             this.handleEvent('cancel', notification); | ||||
|         }); | ||||
| 
 | ||||
|             // Create the default channel for local notifications.
 | ||||
|         this.addSubscription = LocalNotifications.instance.on('schedule').subscribe((notification: ILocalNotification) => { | ||||
|             this.handleEvent('schedule', notification); | ||||
|         }); | ||||
| 
 | ||||
|         this.updateSubscription = LocalNotifications.instance.on('update').subscribe((notification: ILocalNotification) => { | ||||
|             this.handleEvent('update', notification); | ||||
|         }); | ||||
| 
 | ||||
|         // Create the default channel for local notifications.
 | ||||
|         this.createDefaultChannel(); | ||||
| 
 | ||||
|         Translate.instance.onLangChange.subscribe(() => { | ||||
|             // Update the channel name.
 | ||||
|             this.createDefaultChannel(); | ||||
| 
 | ||||
|             Translate.instance.onLangChange.subscribe(() => { | ||||
|                 // Update the channel name.
 | ||||
|                 this.createDefaultChannel(); | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         CoreEvents.instance.on(CoreEventsProvider.SITE_DELETED, (site: CoreSite) => { | ||||
|             if (site) { | ||||
|                 this.cancelSiteNotifications(site.id); | ||||
|                 this.cancelSiteNotifications(site.id!); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| @ -193,13 +200,13 @@ export class CoreLocalNotificationsProvider { | ||||
| 
 | ||||
|         const scheduled = await this.getAllScheduled(); | ||||
| 
 | ||||
|         const ids = []; | ||||
|         const ids: number[] = []; | ||||
|         const queueId = 'cancelSiteNotifications-' + siteId; | ||||
| 
 | ||||
|         scheduled.forEach((notif) => { | ||||
|             notif.data = this.parseNotificationData(notif.data); | ||||
| 
 | ||||
|             if (typeof notif.data == 'object' && notif.data.siteId === siteId) { | ||||
|             if (notif.id && typeof notif.data == 'object' && notif.data.siteId === siteId) { | ||||
|                 ids.push(notif.id); | ||||
|             } | ||||
|         }); | ||||
| @ -355,10 +362,9 @@ export class CoreLocalNotificationsProvider { | ||||
|      * @return Whether local notifications plugin is installed. | ||||
|      */ | ||||
|     isAvailable(): boolean { | ||||
|         const win = <any> window; | ||||
|         const win = <any> window; // eslint-disable-line @typescript-eslint/no-explicit-any
 | ||||
| 
 | ||||
|         return CoreApp.instance.isDesktop() || !!(win.cordova && win.cordova.plugins && win.cordova.plugins.notification && | ||||
|                 win.cordova.plugins.notification.local); | ||||
|         return CoreApp.instance.isDesktop() || !!win.cordova?.plugins?.notification?.local; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -388,11 +394,11 @@ export class CoreLocalNotificationsProvider { | ||||
|             if (useQueue) { | ||||
|                 const queueId = 'isTriggered-' + notification.id; | ||||
| 
 | ||||
|                 return this.queueRunner.run(queueId, () => LocalNotifications.instance.isTriggered(notification.id), { | ||||
|                 return this.queueRunner.run(queueId, () => LocalNotifications.instance.isTriggered(notification.id!), { | ||||
|                     allowRepeated: true, | ||||
|                 }); | ||||
|             } else { | ||||
|                 return LocalNotifications.instance.isTriggered(notification.id); | ||||
|                 return LocalNotifications.instance.isTriggered(notification.id || 0); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @ -446,9 +452,8 @@ export class CoreLocalNotificationsProvider { | ||||
|     /** | ||||
|      * Process the next request in queue. | ||||
|      */ | ||||
|     protected processNextRequest(): void { | ||||
|     protected async processNextRequest(): Promise<void> { | ||||
|         const nextKey = Object.keys(this.codeRequestsQueue)[0]; | ||||
|         let promise: Promise<void>; | ||||
| 
 | ||||
|         if (typeof nextKey == 'undefined') { | ||||
|             // No more requests in queue, stop.
 | ||||
| @ -457,27 +462,27 @@ export class CoreLocalNotificationsProvider { | ||||
| 
 | ||||
|         const request = this.codeRequestsQueue[nextKey]; | ||||
| 
 | ||||
|         // Check if request is valid.
 | ||||
|         if (typeof request == 'object' && typeof request.table != 'undefined' && typeof request.id != 'undefined') { | ||||
|             // Get the code and resolve/reject all the promises of this request.
 | ||||
|             promise = this.getCode(request.table, request.id).then((code) => { | ||||
|                 request.deferreds.forEach((p) => { | ||||
|                     p.resolve(code); | ||||
|                 }); | ||||
|             }).catch((error) => { | ||||
|                 request.deferreds.forEach((p) => { | ||||
|                     p.reject(error); | ||||
|                 }); | ||||
|             }); | ||||
|         } else { | ||||
|             promise = Promise.resolve(); | ||||
|         } | ||||
|         try { | ||||
|             // Check if request is valid.
 | ||||
|             if (typeof request != 'object' || request.table === undefined || request.id === undefined) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|         // Once this item is treated, remove it and process next.
 | ||||
|         promise.finally(() => { | ||||
|             // Get the code and resolve/reject all the promises of this request.
 | ||||
|             const code = await this.getCode(request.table, request.id); | ||||
| 
 | ||||
|             request.deferreds.forEach((p) => { | ||||
|                 p.resolve(code); | ||||
|             }); | ||||
|         } catch (error) { | ||||
|             request.deferreds.forEach((p) => { | ||||
|                 p.reject(error); | ||||
|             }); | ||||
|         } finally { | ||||
|             // Once this item is treated, remove it and process next.
 | ||||
|             delete this.codeRequestsQueue[nextKey]; | ||||
|             this.processNextRequest(); | ||||
|         }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -596,7 +601,7 @@ export class CoreLocalNotificationsProvider { | ||||
|      */ | ||||
|     async schedule(notification: ILocalNotification, component: string, siteId: string, alreadyUnique?: boolean): Promise<void> { | ||||
|         if (!alreadyUnique) { | ||||
|             notification.id = await this.getUniqueNotificationId(notification.id, component, siteId); | ||||
|             notification.id = await this.getUniqueNotificationId(notification.id || 0, component, siteId); | ||||
|         } | ||||
| 
 | ||||
|         notification.data = notification.data || {}; | ||||
| @ -663,7 +668,7 @@ export class CoreLocalNotificationsProvider { | ||||
|                 } | ||||
| 
 | ||||
|                 if (!soundEnabled) { | ||||
|                     notification.sound = null; | ||||
|                     notification.sound = undefined; | ||||
|                 } else { | ||||
|                     delete notification.sound; // Use default value.
 | ||||
|                 } | ||||
| @ -671,7 +676,7 @@ export class CoreLocalNotificationsProvider { | ||||
|                 notification.foreground = true; | ||||
| 
 | ||||
|                 // Remove from triggered, since the notification could be in there with a different time.
 | ||||
|                 this.removeTriggered(notification.id); | ||||
|                 this.removeTriggered(notification.id || 0); | ||||
|                 LocalNotifications.instance.schedule(notification); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
| @ -206,7 +206,7 @@ export class CorePluginFileDelegate extends CoreDelegate { | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return downloadableFile.filesize; | ||||
|         return downloadableFile.filesize || 0; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -215,7 +215,7 @@ export class CorePluginFileDelegate extends CoreDelegate { | ||||
|      * @param file File data. | ||||
|      * @return Handler. | ||||
|      */ | ||||
|     protected getHandlerForFile(file: CoreWSExternalFile): CorePluginFileHandler { | ||||
|     protected getHandlerForFile(file: CoreWSExternalFile): CorePluginFileHandler | undefined { | ||||
|         for (const component in this.enabledHandlers) { | ||||
|             const handler = <CorePluginFileHandler> this.enabledHandlers[component]; | ||||
| 
 | ||||
|  | ||||
| @ -130,7 +130,7 @@ export class CoreSitesProvider { | ||||
| 
 | ||||
|                     // Move the records from the old table.
 | ||||
|                     const sites = await db.getAllRecords<SiteDBEntry>(oldTable); | ||||
|                     const promises = []; | ||||
|                     const promises: Promise<number>[] = []; | ||||
| 
 | ||||
|                     sites.forEach((site) => { | ||||
|                         promises.push(db.insertRecord(newTable, site)); | ||||
| @ -153,12 +153,12 @@ export class CoreSitesProvider { | ||||
|     protected readonly VALID_VERSION = 1; | ||||
|     protected readonly INVALID_VERSION = -1; | ||||
| 
 | ||||
|     protected isWPApp: boolean; | ||||
|     protected isWPApp = false; | ||||
| 
 | ||||
|     protected logger: CoreLogger; | ||||
|     protected services = {}; | ||||
|     protected sessionRestored = false; | ||||
|     protected currentSite: CoreSite; | ||||
|     protected currentSite?: CoreSite; | ||||
|     protected sites: { [s: string]: CoreSite } = {}; | ||||
|     protected appDB: SQLiteDB; | ||||
|     protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized.
 | ||||
| @ -249,7 +249,8 @@ export class CoreSitesProvider { | ||||
|                 await db.execute( | ||||
|                     'INSERT INTO ' + newTable + ' ' + | ||||
|                     'SELECT id, data, key, expirationTime, NULL as component, NULL as componentId ' + | ||||
|                     'FROM ' + oldTable); | ||||
|                     'FROM ' + oldTable, | ||||
|                 ); | ||||
| 
 | ||||
|                 try { | ||||
|                     await db.dropTable(oldTable); | ||||
| @ -276,7 +277,7 @@ export class CoreSitesProvider { | ||||
|      * @param name Name of the site to check. | ||||
|      * @return Site data if it's a demo site, undefined otherwise. | ||||
|      */ | ||||
|     getDemoSiteData(name: string): {[name: string]: CoreSitesDemoSiteData} { | ||||
|     getDemoSiteData(name: string): CoreSitesDemoSiteData | undefined { | ||||
|         const demoSites = CoreConfigConstants.demo_sites; | ||||
|         name = name.toLowerCase(); | ||||
| 
 | ||||
| @ -293,39 +294,43 @@ export class CoreSitesProvider { | ||||
|      * @param protocol Protocol to use first. | ||||
|      * @return A promise resolved when the site is checked. | ||||
|      */ | ||||
|     checkSite(siteUrl: string, protocol: string = 'https://'): Promise<CoreSiteCheckResponse> { | ||||
|     async checkSite(siteUrl: string, protocol: string = 'https://'): Promise<CoreSiteCheckResponse> { | ||||
|         // The formatURL function adds the protocol if is missing.
 | ||||
|         siteUrl = CoreUrlUtils.instance.formatURL(siteUrl); | ||||
| 
 | ||||
|         if (!CoreUrlUtils.instance.isHttpURL(siteUrl)) { | ||||
|             return Promise.reject(new CoreError(Translate.instance.instant('core.login.invalidsite'))); | ||||
|             throw new CoreError(Translate.instance.instant('core.login.invalidsite')); | ||||
|         } else if (!CoreApp.instance.isOnline()) { | ||||
|             return Promise.reject(new CoreError(Translate.instance.instant('core.networkerrormsg'))); | ||||
|         } else { | ||||
|             return this.checkSiteWithProtocol(siteUrl, protocol).catch((error: CoreSiteError) => { | ||||
|                 // Do not continue checking if a critical error happened.
 | ||||
|                 if (error.critical) { | ||||
|                     return Promise.reject(error); | ||||
|             throw new CoreError(Translate.instance.instant('core.networkerrormsg')); | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             return await this.checkSiteWithProtocol(siteUrl, protocol); | ||||
|         } catch (error) { | ||||
|             // Do not continue checking if a critical error happened.
 | ||||
|             if (error.critical) { | ||||
|                 throw error; | ||||
|             } | ||||
| 
 | ||||
|             // Retry with the other protocol.
 | ||||
|             protocol = protocol == 'https://' ? 'http://' : 'https://'; | ||||
| 
 | ||||
|             try { | ||||
|                 return await this.checkSiteWithProtocol(siteUrl, protocol); | ||||
|             } catch (secondError) { | ||||
|                 if (secondError.critical) { | ||||
|                     throw secondError; | ||||
|                 } | ||||
| 
 | ||||
|                 // Retry with the other protocol.
 | ||||
|                 protocol = protocol == 'https://' ? 'http://' : 'https://'; | ||||
| 
 | ||||
|                 return this.checkSiteWithProtocol(siteUrl, protocol).catch((secondError: CoreSiteError) => { | ||||
|                     if (secondError.critical) { | ||||
|                         return Promise.reject(secondError); | ||||
|                     } | ||||
| 
 | ||||
|                     // Site doesn't exist. Return the error message.
 | ||||
|                     if (CoreTextUtils.instance.getErrorMessageFromError(error)) { | ||||
|                         return Promise.reject(error); | ||||
|                     } else if (CoreTextUtils.instance.getErrorMessageFromError(secondError)) { | ||||
|                         return Promise.reject(secondError); | ||||
|                     } else { | ||||
|                         return Translate.instance.instant('core.cannotconnecttrouble'); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|                 // Site doesn't exist. Return the error message.
 | ||||
|                 if (CoreTextUtils.instance.getErrorMessageFromError(error)) { | ||||
|                     throw error; | ||||
|                 } else if (CoreTextUtils.instance.getErrorMessageFromError(secondError)) { | ||||
|                     throw secondError; | ||||
|                 } else { | ||||
|                     throw new CoreError(Translate.instance.instant('core.cannotconnecttrouble')); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -336,121 +341,123 @@ export class CoreSitesProvider { | ||||
|      * @param protocol Protocol to use. | ||||
|      * @return A promise resolved when the site is checked. | ||||
|      */ | ||||
|     checkSiteWithProtocol(siteUrl: string, protocol: string): Promise<CoreSiteCheckResponse> { | ||||
|         let publicConfig: CoreSitePublicConfigResponse; | ||||
|     async checkSiteWithProtocol(siteUrl: string, protocol: string): Promise<CoreSiteCheckResponse> { | ||||
|         let publicConfig: CoreSitePublicConfigResponse | undefined; | ||||
| 
 | ||||
|         // Now, replace the siteUrl with the protocol.
 | ||||
|         siteUrl = siteUrl.replace(/^https?:\/\//i, protocol); | ||||
| 
 | ||||
|         return this.siteExists(siteUrl).catch((error: CoreSiteError) => { | ||||
|         try { | ||||
|             await this.siteExists(siteUrl); | ||||
|         } catch (error) { | ||||
|             // Do not continue checking if WS are not enabled.
 | ||||
|             if (error.errorcode == 'enablewsdescription') { | ||||
|                 error.critical = true; | ||||
| 
 | ||||
|                 return Promise.reject(error); | ||||
|                 throw error; | ||||
|             } | ||||
| 
 | ||||
|             // Site doesn't exist. Try to add or remove 'www'.
 | ||||
|             const treatedUrl = CoreUrlUtils.instance.addOrRemoveWWW(siteUrl); | ||||
| 
 | ||||
|             return this.siteExists(treatedUrl).then(() => { | ||||
|             try { | ||||
|                 await this.siteExists(treatedUrl); | ||||
| 
 | ||||
|                 // Success, use this new URL as site url.
 | ||||
|                 siteUrl = treatedUrl; | ||||
|             }).catch((secondError: CoreSiteError) => { | ||||
|             } catch (secondError) { | ||||
|                 // Do not continue checking if WS are not enabled.
 | ||||
|                 if (secondError.errorcode == 'enablewsdescription') { | ||||
|                     secondError.critical = true; | ||||
| 
 | ||||
|                     return Promise.reject(secondError); | ||||
|                     throw secondError; | ||||
|                 } | ||||
| 
 | ||||
|                 // Return the error.
 | ||||
|                 if (CoreTextUtils.instance.getErrorMessageFromError(error)) { | ||||
|                     return Promise.reject(error); | ||||
|                     throw error; | ||||
|                 } else { | ||||
|                     return Promise.reject(secondError); | ||||
|                     throw secondError; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Site exists. Create a temporary site to check if local_mobile is installed.
 | ||||
|         const temporarySite = new CoreSite(undefined, siteUrl); | ||||
|         let data: LocalMobileResponse; | ||||
| 
 | ||||
|         try { | ||||
|             data = await temporarySite.checkLocalMobilePlugin(); | ||||
|         } catch (error) { | ||||
|             // Local mobile check returned an error. This only happens if the plugin is installed and it returns an error.
 | ||||
|             throw new CoreSiteError({ | ||||
|                 message: error.message, | ||||
|                 critical: true, | ||||
|             }); | ||||
|         }).then(() => { | ||||
|             // Create a temporary site to check if local_mobile is installed.
 | ||||
|             const temporarySite = new CoreSite(undefined, siteUrl); | ||||
|         } | ||||
| 
 | ||||
|             return temporarySite.checkLocalMobilePlugin().then((data) => { | ||||
|                 data.service = data.service || CoreConfigConstants.wsservice; | ||||
|                 this.services[siteUrl] = data.service; // No need to store it in DB.
 | ||||
|         data.service = data.service || CoreConfigConstants.wsservice; | ||||
|         this.services[siteUrl] = data.service; // No need to store it in DB.
 | ||||
| 
 | ||||
|                 if (data.coreSupported || | ||||
|                     (data.code != CoreConstants.LOGIN_SSO_CODE && data.code != CoreConstants.LOGIN_SSO_INAPP_CODE)) { | ||||
|                     // SSO using local_mobile not needed, try to get the site public config.
 | ||||
|                     return temporarySite.getPublicConfig().then((config) => { | ||||
|                         publicConfig = config; | ||||
|         if (data.coreSupported || (data.code != CoreConstants.LOGIN_SSO_CODE && data.code != CoreConstants.LOGIN_SSO_INAPP_CODE)) { | ||||
|             // SSO using local_mobile not needed, try to get the site public config.
 | ||||
|             try { | ||||
|                 const config = await temporarySite.getPublicConfig(); | ||||
| 
 | ||||
|                         // Check that the user can authenticate.
 | ||||
|                         if (!config.enablewebservices) { | ||||
|                             return Promise.reject(new CoreSiteError({ | ||||
|                                 message: Translate.instance.instant('core.login.webservicesnotenabled'), | ||||
|                             })); | ||||
|                         } else if (!config.enablemobilewebservice) { | ||||
|                             return Promise.reject(new CoreSiteError({ | ||||
|                                 message: Translate.instance.instant('core.login.mobileservicesnotenabled'), | ||||
|                             })); | ||||
|                         } else if (config.maintenanceenabled) { | ||||
|                             let message = Translate.instance.instant('core.sitemaintenance'); | ||||
|                             if (config.maintenancemessage) { | ||||
|                                 message += config.maintenancemessage; | ||||
|                             } | ||||
|                 publicConfig = config; | ||||
| 
 | ||||
|                             return Promise.reject(new CoreSiteError({ | ||||
|                                 message, | ||||
|                             })); | ||||
|                         } | ||||
|                 // Check that the user can authenticate.
 | ||||
|                 if (!config.enablewebservices) { | ||||
|                     throw new CoreSiteError({ | ||||
|                         message: Translate.instance.instant('core.login.webservicesnotenabled'), | ||||
|                     }); | ||||
|                 } else if (!config.enablemobilewebservice) { | ||||
|                     throw new CoreSiteError({ | ||||
|                         message: Translate.instance.instant('core.login.mobileservicesnotenabled'), | ||||
|                     }); | ||||
|                 } else if (config.maintenanceenabled) { | ||||
|                     let message = Translate.instance.instant('core.sitemaintenance'); | ||||
|                     if (config.maintenancemessage) { | ||||
|                         message += config.maintenancemessage; | ||||
|                     } | ||||
| 
 | ||||
|                         // Everything ok.
 | ||||
|                         if (data.code === 0) { | ||||
|                             data.code = config.typeoflogin; | ||||
|                         } | ||||
| 
 | ||||
|                         return data; | ||||
|                     }, async (error) => { | ||||
|                         // Error, check if not supported.
 | ||||
|                         if (error.available === 1) { | ||||
|                             // Service supported but an error happened. Return error.
 | ||||
|                             if (error.errorcode == 'codingerror') { | ||||
|                                 // This could be caused by a redirect. Check if it's the case.
 | ||||
|                                 const redirect = await CoreUtils.instance.checkRedirect(siteUrl); | ||||
| 
 | ||||
|                                 if (redirect) { | ||||
|                                     error.error = Translate.instance.instant('core.login.sitehasredirect'); | ||||
|                                 } else { | ||||
|                                     // We can't be sure if there is a redirect or not. Display cannot connect error.
 | ||||
|                                     error.error = Translate.instance.instant('core.cannotconnecttrouble'); | ||||
|                                 } | ||||
|                             } | ||||
| 
 | ||||
|                             return Promise.reject(new CoreSiteError({ | ||||
|                                 message: error.error, | ||||
|                                 errorcode: error.errorcode, | ||||
|                                 critical: true, | ||||
|                             })); | ||||
|                         } | ||||
| 
 | ||||
|                         return data; | ||||
|                     throw new CoreSiteError({ | ||||
|                         message, | ||||
|                     }); | ||||
|                 } | ||||
| 
 | ||||
|                 return data; | ||||
|             }, (error: CoreError) => | ||||
|                 // Local mobile check returned an error. This only happens if the plugin is installed and it returns an error.
 | ||||
|                 Promise.reject(new CoreSiteError({ | ||||
|                     message: error.message, | ||||
|                     critical: true, | ||||
|                 })), | ||||
|             ).then((data: LocalMobileResponse) => { | ||||
|                 siteUrl = temporarySite.getURL(); | ||||
|                 // Everything ok.
 | ||||
|                 if (data.code === 0) { | ||||
|                     data.code = config.typeoflogin; | ||||
|                 } | ||||
|             } catch (error) { | ||||
|                 // Error, check if not supported.
 | ||||
|                 if (error.available === 1) { | ||||
|                     // Service supported but an error happened. Return error.
 | ||||
|                     if (error.errorcode == 'codingerror') { | ||||
|                         // This could be caused by a redirect. Check if it's the case.
 | ||||
|                         const redirect = await CoreUtils.instance.checkRedirect(siteUrl); | ||||
| 
 | ||||
|                 return { siteUrl, code: data.code, warning: data.warning, service: data.service, config: publicConfig }; | ||||
|             }); | ||||
|         }); | ||||
|                         if (redirect) { | ||||
|                             error.error = Translate.instance.instant('core.login.sitehasredirect'); | ||||
|                         } else { | ||||
|                             // We can't be sure if there is a redirect or not. Display cannot connect error.
 | ||||
|                             error.error = Translate.instance.instant('core.cannotconnecttrouble'); | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     throw new CoreSiteError({ | ||||
|                         message: error.error, | ||||
|                         errorcode: error.errorcode, | ||||
|                         critical: true, | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         siteUrl = temporarySite.getURL(); | ||||
| 
 | ||||
|         return { siteUrl, code: data.code, warning: data.warning, service: data.service, config: publicConfig }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -482,7 +489,7 @@ export class CoreSitesProvider { | ||||
|         if (data.errorcode && (data.errorcode == 'enablewsdescription' || data.errorcode == 'requirecorrectaccess')) { | ||||
|             throw new CoreSiteError({ | ||||
|                 errorcode: data.errorcode, | ||||
|                 message: data.error, | ||||
|                 message: data.error!, | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
| @ -506,10 +513,15 @@ export class CoreSitesProvider { | ||||
|      * @param retry Whether we are retrying with a prefixed URL. | ||||
|      * @return A promise resolved when the token is retrieved. | ||||
|      */ | ||||
|     getUserToken(siteUrl: string, username: string, password: string, service?: string, retry?: boolean): | ||||
|             Promise<CoreSiteUserTokenResponse> { | ||||
|     async getUserToken( | ||||
|         siteUrl: string, | ||||
|         username: string, | ||||
|         password: string, | ||||
|         service?: string, | ||||
|         retry?: boolean, | ||||
|     ): Promise<CoreSiteUserTokenResponse> { | ||||
|         if (!CoreApp.instance.isOnline()) { | ||||
|             return Promise.reject(new CoreError(Translate.instance.instant('core.networkerrormsg'))); | ||||
|             throw new CoreError(Translate.instance.instant('core.networkerrormsg')); | ||||
|         } | ||||
| 
 | ||||
|         if (!service) { | ||||
| @ -522,47 +534,46 @@ export class CoreSitesProvider { | ||||
|             service, | ||||
|         }; | ||||
|         const loginUrl = siteUrl + '/login/token.php'; | ||||
|         const promise = Http.instance.post(loginUrl, params).pipe(timeout(CoreWS.instance.getRequestTimeout())).toPromise(); | ||||
|         let data: CoreSitesLoginTokenResponse; | ||||
| 
 | ||||
|         return promise.then((data: CoreSitesLoginTokenResponse) => { | ||||
|             if (typeof data == 'undefined') { | ||||
|                 return Promise.reject(new CoreError(Translate.instance.instant('core.cannotconnecttrouble'))); | ||||
|         try { | ||||
|             data = await Http.instance.post(loginUrl, params).pipe(timeout(CoreWS.instance.getRequestTimeout())).toPromise(); | ||||
|         } catch (error) { | ||||
|             throw new CoreError(Translate.instance.instant('core.cannotconnecttrouble')); | ||||
|         } | ||||
| 
 | ||||
|         if (typeof data == 'undefined') { | ||||
|             throw new CoreError(Translate.instance.instant('core.cannotconnecttrouble')); | ||||
|         } else { | ||||
|             if (typeof data.token != 'undefined') { | ||||
|                 return { token: data.token, siteUrl, privateToken: data.privatetoken }; | ||||
|             } else { | ||||
|                 if (typeof data.token != 'undefined') { | ||||
|                     return { token: data.token, siteUrl, privateToken: data.privatetoken }; | ||||
|                 } else { | ||||
|                     if (typeof data.error != 'undefined') { | ||||
|                         // We only allow one retry (to avoid loops).
 | ||||
|                         if (!retry && data.errorcode == 'requirecorrectaccess') { | ||||
|                             siteUrl = CoreUrlUtils.instance.addOrRemoveWWW(siteUrl); | ||||
|                 if (typeof data.error != 'undefined') { | ||||
|                     // We only allow one retry (to avoid loops).
 | ||||
|                     if (!retry && data.errorcode == 'requirecorrectaccess') { | ||||
|                         siteUrl = CoreUrlUtils.instance.addOrRemoveWWW(siteUrl); | ||||
| 
 | ||||
|                             return this.getUserToken(siteUrl, username, password, service, true); | ||||
|                         } else if (data.errorcode == 'missingparam') { | ||||
|                             // It seems the server didn't receive all required params, it could be due to a redirect.
 | ||||
|                             return CoreUtils.instance.checkRedirect(loginUrl).then((redirect) => { | ||||
|                                 if (redirect) { | ||||
|                                     return Promise.reject(new CoreSiteError({ | ||||
|                                         message: Translate.instance.instant('core.login.sitehasredirect'), | ||||
|                                     })); | ||||
|                                 } else { | ||||
|                                     return Promise.reject(new CoreSiteError({ | ||||
|                                         message: data.error, | ||||
|                                         errorcode: data.errorcode, | ||||
|                                     })); | ||||
|                                 } | ||||
|                         return this.getUserToken(siteUrl, username, password, service, true); | ||||
|                     } else if (data.errorcode == 'missingparam') { | ||||
|                         // It seems the server didn't receive all required params, it could be due to a redirect.
 | ||||
|                         const redirect = await CoreUtils.instance.checkRedirect(loginUrl); | ||||
| 
 | ||||
|                         if (redirect) { | ||||
|                             throw new CoreSiteError({ | ||||
|                                 message: Translate.instance.instant('core.login.sitehasredirect'), | ||||
|                             }); | ||||
|                         } else { | ||||
|                             return Promise.reject(new CoreSiteError({ | ||||
|                                 message: data.error, | ||||
|                                 errorcode: data.errorcode, | ||||
|                             })); | ||||
|                         } | ||||
|                     } else { | ||||
|                         return Promise.reject(new CoreError(Translate.instance.instant('core.login.invalidaccount'))); | ||||
|                     } | ||||
| 
 | ||||
|                     throw new CoreSiteError({ | ||||
|                         message: data.error, | ||||
|                         errorcode: data.errorcode, | ||||
|                     }); | ||||
|                 } | ||||
| 
 | ||||
|                 throw new CoreError(Translate.instance.instant('core.login.invalidaccount')); | ||||
|             } | ||||
|         }, () => Promise.reject(new CoreError(Translate.instance.instant('core.cannotconnecttrouble')))); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -575,7 +586,13 @@ export class CoreSitesProvider { | ||||
|      * @param oauthId OAuth ID. Only if the authentication was using an OAuth method. | ||||
|      * @return A promise resolved with siteId when the site is added and the user is authenticated. | ||||
|      */ | ||||
|     newSite(siteUrl: string, token: string, privateToken: string = '', login: boolean = true, oauthId?: number): Promise<string> { | ||||
|     async newSite( | ||||
|         siteUrl: string, | ||||
|         token: string, | ||||
|         privateToken: string = '', | ||||
|         login: boolean = true, | ||||
|         oauthId?: number, | ||||
|     ): Promise<string> { | ||||
|         if (typeof login != 'boolean') { | ||||
|             login = true; | ||||
|         } | ||||
| @ -584,74 +601,77 @@ export class CoreSitesProvider { | ||||
|         let candidateSite = new CoreSite(undefined, siteUrl, token, undefined, privateToken, undefined, undefined); | ||||
|         let isNewSite = true; | ||||
| 
 | ||||
|         return candidateSite.fetchSiteInfo().then((info) => { | ||||
|         try { | ||||
|             const info = await candidateSite.fetchSiteInfo(); | ||||
| 
 | ||||
|             const result = this.isValidMoodleVersion(info); | ||||
|             if (result == this.VALID_VERSION) { | ||||
|                 const siteId = this.createSiteID(info.siteurl, info.username); | ||||
| 
 | ||||
|                 // Check if the site already exists.
 | ||||
|                 return this.getSite(siteId).catch(() => { | ||||
|                     // Not exists.
 | ||||
|                 }).then((site) => { | ||||
|                     if (site) { | ||||
|                         // Site already exists, update its data and use it.
 | ||||
|                         isNewSite = false; | ||||
|                         candidateSite = site; | ||||
|                         candidateSite.setToken(token); | ||||
|                         candidateSite.setPrivateToken(privateToken); | ||||
|                         candidateSite.setInfo(info); | ||||
|                         candidateSite.setOAuthId(oauthId); | ||||
|                         candidateSite.setLoggedOut(false); | ||||
|                     } else { | ||||
|                         // New site, set site ID and info.
 | ||||
|                         isNewSite = true; | ||||
|                         candidateSite.setId(siteId); | ||||
|                         candidateSite.setInfo(info); | ||||
|                         candidateSite.setOAuthId(oauthId); | ||||
| 
 | ||||
|                         // Create database tables before login and before any WS call.
 | ||||
|                         return this.migrateSiteSchemas(candidateSite); | ||||
|                     } | ||||
|                 }).then(() => | ||||
| 
 | ||||
|                     // Try to get the site config.
 | ||||
|                     this.getSiteConfig(candidateSite).catch((error) => { | ||||
|                         // Ignore errors if it's not a new site, we'll use the config already stored.
 | ||||
|                         if (isNewSite) { | ||||
|                             return Promise.reject(error); | ||||
|                         } | ||||
|                     }).then((config) => { | ||||
|                         if (typeof config != 'undefined') { | ||||
|                             candidateSite.setConfig(config); | ||||
|                         } | ||||
| 
 | ||||
|                         // Add site to sites list.
 | ||||
|                         this.addSite(siteId, siteUrl, token, info, privateToken, config, oauthId); | ||||
|                         this.sites[siteId] = candidateSite; | ||||
| 
 | ||||
|                         if (login) { | ||||
|                             // Turn candidate site into current site.
 | ||||
|                             this.currentSite = candidateSite; | ||||
|                             // Store session.
 | ||||
|                             this.login(siteId); | ||||
|                         } | ||||
| 
 | ||||
|                         CoreEvents.instance.trigger(CoreEventsProvider.SITE_ADDED, info, siteId); | ||||
| 
 | ||||
|                         return siteId; | ||||
|                     }), | ||||
|                 ); | ||||
|             if (result != this.VALID_VERSION) { | ||||
|                 return this.treatInvalidAppVersion(result, siteUrl); | ||||
|             } | ||||
| 
 | ||||
|             return this.treatInvalidAppVersion(result, siteUrl); | ||||
|         }).catch((error) => { | ||||
|             const siteId = this.createSiteID(info.siteurl, info.username); | ||||
| 
 | ||||
|             // Check if the site already exists.
 | ||||
|             const site = await CoreUtils.instance.ignoreErrors<CoreSite>(this.getSite(siteId)); | ||||
| 
 | ||||
|             if (site) { | ||||
|                 // Site already exists, update its data and use it.
 | ||||
|                 isNewSite = false; | ||||
|                 candidateSite = site; | ||||
|                 candidateSite.setToken(token); | ||||
|                 candidateSite.setPrivateToken(privateToken); | ||||
|                 candidateSite.setInfo(info); | ||||
|                 candidateSite.setOAuthId(oauthId); | ||||
|                 candidateSite.setLoggedOut(false); | ||||
|             } else { | ||||
|                 // New site, set site ID and info.
 | ||||
|                 isNewSite = true; | ||||
|                 candidateSite.setId(siteId); | ||||
|                 candidateSite.setInfo(info); | ||||
|                 candidateSite.setOAuthId(oauthId); | ||||
| 
 | ||||
|                 // Create database tables before login and before any WS call.
 | ||||
|                 await this.migrateSiteSchemas(candidateSite); | ||||
|             } | ||||
| 
 | ||||
|             // Try to get the site config.
 | ||||
|             let config: CoreSiteConfig | undefined; | ||||
| 
 | ||||
|             try { | ||||
|                 config = await this.getSiteConfig(candidateSite); | ||||
|             } catch (error) { | ||||
|                 // Ignore errors if it's not a new site, we'll use the config already stored.
 | ||||
|                 if (isNewSite) { | ||||
|                     throw error; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             if (typeof config != 'undefined') { | ||||
|                 candidateSite.setConfig(config); | ||||
|             } | ||||
| 
 | ||||
|             // Add site to sites list.
 | ||||
|             this.addSite(siteId, siteUrl, token, info, privateToken, config, oauthId); | ||||
|             this.sites[siteId] = candidateSite; | ||||
| 
 | ||||
|             if (login) { | ||||
|                 // Turn candidate site into current site.
 | ||||
|                 this.currentSite = candidateSite; | ||||
|                 // Store session.
 | ||||
|                 this.login(siteId); | ||||
|             } | ||||
| 
 | ||||
|             CoreEvents.instance.trigger(CoreEventsProvider.SITE_ADDED, info, siteId); | ||||
| 
 | ||||
|             return siteId; | ||||
|         } catch (error) { | ||||
|             // Error invaliddevice is returned by Workplace server meaning the same as connecttoworkplaceapp.
 | ||||
|             if (error && error.errorcode == 'invaliddevice') { | ||||
|                 return this.treatInvalidAppVersion(this.WORKPLACE_APP, siteUrl); | ||||
|             } | ||||
| 
 | ||||
|             return Promise.reject(error); | ||||
|         }); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -663,8 +683,8 @@ export class CoreSitesProvider { | ||||
|      * @return A promise rejected with the error info. | ||||
|      */ | ||||
|     protected async treatInvalidAppVersion(result: number, siteUrl: string, siteId?: string): Promise<never> { | ||||
|         let errorCode; | ||||
|         let errorKey; | ||||
|         let errorCode: string | undefined; | ||||
|         let errorKey: string | undefined; | ||||
|         let translateParams; | ||||
| 
 | ||||
|         switch (result) { | ||||
| @ -816,8 +836,15 @@ export class CoreSitesProvider { | ||||
|      * @param oauthId OAuth ID. Only if the authentication was using an OAuth method. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async addSite(id: string, siteUrl: string, token: string, info: CoreSiteInfoResponse, privateToken: string = '', | ||||
|             config?: CoreSiteConfig, oauthId?: number): Promise<void> { | ||||
|     async addSite( | ||||
|         id: string, | ||||
|         siteUrl: string, | ||||
|         token: string, | ||||
|         info: CoreSiteInfoResponse, | ||||
|         privateToken: string = '', | ||||
|         config?: CoreSiteConfig, | ||||
|         oauthId?: number, | ||||
|     ): Promise<void> { | ||||
|         await this.dbReady; | ||||
| 
 | ||||
|         const entry = { | ||||
| @ -850,47 +877,55 @@ export class CoreSitesProvider { | ||||
|      * @param siteId ID of the site to check. Current site id will be used otherwise. | ||||
|      * @return Resolved with  if meets the requirements, rejected otherwise. | ||||
|      */ | ||||
|     async checkRequiredMinimumVersion(config: CoreSitePublicConfigResponse, siteId?: string): Promise<void> { | ||||
|         if (config && config.tool_mobile_minimumversion) { | ||||
|             const requiredVersion = this.convertVersionName(config.tool_mobile_minimumversion); | ||||
|             const appVersion = this.convertVersionName(CoreConfigConstants.versionname); | ||||
|     async checkRequiredMinimumVersion(config?: CoreSitePublicConfigResponse, siteId?: string): Promise<void> { | ||||
|         if (!config || !config.tool_mobile_minimumversion) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|             if (requiredVersion > appVersion) { | ||||
|                 const storesConfig: CoreStoreConfig = { | ||||
|                     android: config.tool_mobile_androidappid || null, | ||||
|                     ios: config.tool_mobile_iosappid || null, | ||||
|                     desktop: config.tool_mobile_setuplink || 'https://download.moodle.org/desktop/', | ||||
|                     mobile: config.tool_mobile_setuplink || 'https://download.moodle.org/mobile/', | ||||
|                     default: config.tool_mobile_setuplink, | ||||
|                 }; | ||||
|         const requiredVersion = this.convertVersionName(config.tool_mobile_minimumversion); | ||||
|         const appVersion = this.convertVersionName(CoreConfigConstants.versionname); | ||||
| 
 | ||||
|                 const downloadUrl = CoreApp.instance.getAppStoreUrl(storesConfig); | ||||
|         if (requiredVersion > appVersion) { | ||||
|             const storesConfig: CoreStoreConfig = { | ||||
|                 android: config.tool_mobile_androidappid, | ||||
|                 ios: config.tool_mobile_iosappid, | ||||
|                 desktop: config.tool_mobile_setuplink || 'https://download.moodle.org/desktop/', | ||||
|                 mobile: config.tool_mobile_setuplink || 'https://download.moodle.org/mobile/', | ||||
|                 default: config.tool_mobile_setuplink, | ||||
|             }; | ||||
| 
 | ||||
|                 siteId = siteId || this.getCurrentSiteId(); | ||||
|             siteId = siteId || this.getCurrentSiteId(); | ||||
| 
 | ||||
|             const downloadUrl = CoreApp.instance.getAppStoreUrl(storesConfig); | ||||
| 
 | ||||
|             if (downloadUrl != null) { | ||||
|                 // Do not block interface.
 | ||||
|                 CoreDomUtils.instance.showConfirm( | ||||
|                     Translate.instance.instant('core.updaterequireddesc', { $a: config.tool_mobile_minimumversion }), | ||||
|                     Translate.instance.instant('core.updaterequired'), | ||||
|                     Translate.instance.instant('core.download'), | ||||
|                     Translate.instance.instant(siteId ? 'core.mainmenu.logout' : 'core.cancel')).then(() => { | ||||
|                     CoreUtils.instance.openInBrowser(downloadUrl); | ||||
|                 }).catch(() => { | ||||
|                     Translate.instance.instant(siteId ? 'core.mainmenu.logout' : 'core.cancel'), | ||||
|                 ).then(() => CoreUtils.instance.openInBrowser(downloadUrl)).catch(() => { | ||||
|                     // Do nothing.
 | ||||
|                 }); | ||||
|             } else { | ||||
|                 CoreDomUtils.instance.showAlert( | ||||
|                     Translate.instance.instant('core.updaterequired'), | ||||
|                     Translate.instance.instant('core.updaterequireddesc', { $a: config.tool_mobile_minimumversion }), | ||||
|                 ); | ||||
|             } | ||||
| 
 | ||||
|                 if (siteId) { | ||||
|                     // Logout if it's the currentSite.
 | ||||
|                     if (siteId == this.getCurrentSiteId()) { | ||||
|                         await this.logout(); | ||||
|                     } | ||||
| 
 | ||||
|                     // Always expire the token.
 | ||||
|                     await this.setSiteLoggedOut(siteId, true); | ||||
|             if (siteId) { | ||||
|                 // Logout if it's the currentSite.
 | ||||
|                 if (siteId == this.getCurrentSiteId()) { | ||||
|                     await this.logout(); | ||||
|                 } | ||||
| 
 | ||||
|                 throw new CoreError('Current app version is lower than required version.'); | ||||
|                 // Always expire the token.
 | ||||
|                 await this.setSiteLoggedOut(siteId, true); | ||||
|             } | ||||
| 
 | ||||
|             throw new CoreError('Current app version is lower than required version.'); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -952,7 +987,7 @@ export class CoreSitesProvider { | ||||
| 
 | ||||
|             return false; | ||||
|         } catch (error) { | ||||
|             let config: CoreSitePublicConfigResponse; | ||||
|             let config: CoreSitePublicConfigResponse | undefined; | ||||
| 
 | ||||
|             try { | ||||
|                 config = await site.getPublicConfig(); | ||||
| @ -979,7 +1014,7 @@ export class CoreSitesProvider { | ||||
|      * | ||||
|      * @return Current site. | ||||
|      */ | ||||
|     getCurrentSite(): CoreSite { | ||||
|     getCurrentSite(): CoreSite | undefined { | ||||
|         return this.currentSite; | ||||
|     } | ||||
| 
 | ||||
| @ -1015,11 +1050,7 @@ export class CoreSitesProvider { | ||||
|      * @return Current site User ID. | ||||
|      */ | ||||
|     getCurrentSiteUserId(): number { | ||||
|         if (this.currentSite) { | ||||
|             return this.currentSite.getUserId(); | ||||
|         } else { | ||||
|             return 0; | ||||
|         } | ||||
|         return this.currentSite?.getUserId() || 0; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -1150,7 +1181,7 @@ export class CoreSitesProvider { | ||||
|      * @param siteId The site ID. If not defined, current site (if available). | ||||
|      * @return Promise resolved with the database. | ||||
|      */ | ||||
|     getSiteDb(siteId: string): Promise<SQLiteDB> { | ||||
|     getSiteDb(siteId?: string): Promise<SQLiteDB> { | ||||
|         return this.getSite(siteId).then((site) => site.getDb()); | ||||
|     } | ||||
| 
 | ||||
| @ -1175,7 +1206,7 @@ export class CoreSitesProvider { | ||||
| 
 | ||||
|         const sites = await this.appDB.getAllRecords<SiteDBEntry>(SITES_TABLE); | ||||
| 
 | ||||
|         const formattedSites = []; | ||||
|         const formattedSites: CoreSiteBasicInfo[] = []; | ||||
|         sites.forEach((site) => { | ||||
|             if (!ids || ids.indexOf(site.id) > -1) { | ||||
|                 // Parse info.
 | ||||
| @ -1184,7 +1215,7 @@ export class CoreSitesProvider { | ||||
|                     id: site.id, | ||||
|                     siteUrl: site.siteUrl, | ||||
|                     fullName: siteInfo?.fullname, | ||||
|                     siteName: CoreConfigConstants.sitename ? CoreConfigConstants.sitename : siteInfo?.sitename, | ||||
|                     siteName: CoreConfigConstants.sitename ?? siteInfo?.sitename, | ||||
|                     avatar: siteInfo?.userpictureurl, | ||||
|                     siteHomeId: siteInfo?.siteid || 1, | ||||
|                 }; | ||||
| @ -1206,19 +1237,23 @@ export class CoreSitesProvider { | ||||
|             // Sort sites by url and ful lname.
 | ||||
|             sites.sort((a, b) => { | ||||
|                 // First compare by site url without the protocol.
 | ||||
|                 let compareA = a.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); | ||||
|                 let compareB = b.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); | ||||
|                 const compare = compareA.localeCompare(compareB); | ||||
|                 const urlA = a.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); | ||||
|                 const urlB = b.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); | ||||
|                 const compare = urlA.localeCompare(urlB); | ||||
| 
 | ||||
|                 if (compare !== 0) { | ||||
|                     return compare; | ||||
|                 } | ||||
| 
 | ||||
|                 // If site url is the same, use fullname instead.
 | ||||
|                 compareA = a.fullName.toLowerCase().trim(); | ||||
|                 compareB = b.fullName.toLowerCase().trim(); | ||||
|                 const fullNameA = a.fullName?.toLowerCase().trim(); | ||||
|                 const fullNameB = b.fullName?.toLowerCase().trim(); | ||||
| 
 | ||||
|                 return compareA.localeCompare(compareB); | ||||
|                 if (!fullNameA || !fullNameB) { | ||||
|                     return 0; | ||||
|                 } | ||||
| 
 | ||||
|                 return fullNameA.localeCompare(fullNameB); | ||||
|             }); | ||||
| 
 | ||||
|             return sites; | ||||
| @ -1279,10 +1314,10 @@ export class CoreSitesProvider { | ||||
|         await this.dbReady; | ||||
| 
 | ||||
|         let siteId; | ||||
|         const promises = []; | ||||
|         const promises: Promise<unknown>[] = []; | ||||
| 
 | ||||
|         if (this.currentSite) { | ||||
|             const siteConfig = <CoreSiteConfig> this.currentSite.getStoredConfig(); | ||||
|             const siteConfig = this.currentSite.getStoredConfig(); | ||||
|             siteId = this.currentSite.getId(); | ||||
| 
 | ||||
|             this.currentSite = undefined; | ||||
| @ -1418,7 +1453,7 @@ export class CoreSitesProvider { | ||||
|             } | ||||
| 
 | ||||
|             // Try to get the site config.
 | ||||
|             let config; | ||||
|             let config: CoreSiteConfig | undefined; | ||||
| 
 | ||||
|             try { | ||||
|                 config = await this.getSiteConfig(site); | ||||
| @ -1426,10 +1461,9 @@ export class CoreSitesProvider { | ||||
|                 // Error getting config, keep the current one.
 | ||||
|             } | ||||
| 
 | ||||
|             const newValues = { | ||||
|             const newValues: Record<string, string | number> = { | ||||
|                 info: JSON.stringify(info), | ||||
|                 loggedOut: site.isLoggedOut() ? 1 : 0, | ||||
|                 config: undefined, | ||||
|             }; | ||||
| 
 | ||||
|             if (typeof config != 'undefined') { | ||||
| @ -1475,7 +1509,7 @@ export class CoreSitesProvider { | ||||
| 
 | ||||
|         // If prioritize is true, check current site first.
 | ||||
|         if (prioritize && this.currentSite && this.currentSite.containsUrl(url)) { | ||||
|             if (!username || this.currentSite.getInfo().username == username) { | ||||
|             if (!username || this.currentSite?.getInfo()?.username == username) { | ||||
|                 return [this.currentSite.getId()]; | ||||
|             } | ||||
|         } | ||||
| @ -1498,8 +1532,8 @@ export class CoreSitesProvider { | ||||
| 
 | ||||
|         try { | ||||
|             const siteEntries = await this.appDB.getAllRecords<SiteDBEntry>(SITES_TABLE); | ||||
|             const ids = []; | ||||
|             const promises = []; | ||||
|             const ids: string[] = []; | ||||
|             const promises: Promise<unknown>[] = []; | ||||
| 
 | ||||
|             siteEntries.forEach((site) => { | ||||
|                 if (!this.sites[site.id]) { | ||||
| @ -1507,7 +1541,7 @@ export class CoreSitesProvider { | ||||
|                 } | ||||
| 
 | ||||
|                 if (this.sites[site.id].containsUrl(url)) { | ||||
|                     if (!username || this.sites[site.id].getInfo().username == username) { | ||||
|                     if (!username || this.sites[site.id].getInfo()?.username == username) { | ||||
|                         ids.push(site.id); | ||||
|                     } | ||||
|                 } | ||||
| @ -1553,15 +1587,13 @@ export class CoreSitesProvider { | ||||
|      * @param site The site to get the config. | ||||
|      * @return Promise resolved with config if available. | ||||
|      */ | ||||
|     protected async getSiteConfig(site: CoreSite): Promise<CoreSiteConfig> { | ||||
|     protected async getSiteConfig(site: CoreSite): Promise<CoreSiteConfig | undefined> { | ||||
|         if (!site.wsAvailable('tool_mobile_get_config')) { | ||||
|             // WS not available, cannot get config.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const config = <CoreSiteConfig> await site.getConfig(undefined, true); | ||||
| 
 | ||||
|         return config; | ||||
|         return await site.getConfig(undefined, true); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -1611,7 +1643,7 @@ export class CoreSitesProvider { | ||||
|     wsAvailableInCurrentSite(method: string, checkPrefix: boolean = true): boolean { | ||||
|         const site = this.getCurrentSite(); | ||||
| 
 | ||||
|         return site && site.wsAvailable(method, checkPrefix); | ||||
|         return site ? site.wsAvailable(method, checkPrefix) : false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -1659,6 +1691,10 @@ export class CoreSitesProvider { | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     migrateSiteSchemas(site: CoreSite): Promise<void> { | ||||
|         if (!site.id) { | ||||
|             return Promise.resolve(); | ||||
|         } | ||||
| 
 | ||||
|         if (this.siteSchemasMigration[site.id]) { | ||||
|             return this.siteSchemasMigration[site.id]; | ||||
|         } | ||||
| @ -1672,7 +1708,7 @@ export class CoreSitesProvider { | ||||
|         this.siteSchemasMigration[site.id] = promise; | ||||
| 
 | ||||
|         return promise.finally(() => { | ||||
|             delete this.siteSchemasMigration[site.id]; | ||||
|             delete this.siteSchemasMigration[site.id!]; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| @ -1694,7 +1730,7 @@ export class CoreSitesProvider { | ||||
|             versions[record.name] = record.version; | ||||
|         }); | ||||
| 
 | ||||
|         const promises = []; | ||||
|         const promises: Promise<void>[] = []; | ||||
|         for (const name in schemas) { | ||||
|             const schema = schemas[name]; | ||||
|             const oldVersion = versions[name] || 0; | ||||
| @ -1720,6 +1756,10 @@ export class CoreSitesProvider { | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async applySiteSchema(site: CoreSite, schema: CoreRegisteredSiteSchema, oldVersion: number): Promise<void> { | ||||
|         if (!site.id) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const db = site.getDb(); | ||||
| 
 | ||||
|         if (schema.tables) { | ||||
| @ -1741,31 +1781,31 @@ export class CoreSitesProvider { | ||||
|      * @return Promise resolved with site to use and the list of sites that have | ||||
|      *         the URL. Site will be undefined if it isn't the root URL of any stored site. | ||||
|      */ | ||||
|     isStoredRootURL(url: string, username?: string): Promise<{site: CoreSite; siteIds: string[]}> { | ||||
|     async isStoredRootURL(url: string, username?: string): Promise<{site?: CoreSite; siteIds: string[]}> { | ||||
|         // Check if the site is stored.
 | ||||
|         return this.getSiteIdsFromUrl(url, true, username).then((siteIds) => { | ||||
|             const result = { | ||||
|                 siteIds, | ||||
|                 site: undefined, | ||||
|             }; | ||||
|         const siteIds = await this.getSiteIdsFromUrl(url, true, username); | ||||
| 
 | ||||
|             if (siteIds.length > 0) { | ||||
|                 // If more than one site is returned it usually means there are different users stored. Use any of them.
 | ||||
|                 return this.getSite(siteIds[0]).then((site) => { | ||||
|                     const siteUrl = CoreTextUtils.instance.removeEndingSlash( | ||||
|                         CoreUrlUtils.instance.removeProtocolAndWWW(site.getURL())); | ||||
|                     const treatedUrl = CoreTextUtils.instance.removeEndingSlash(CoreUrlUtils.instance.removeProtocolAndWWW(url)); | ||||
| 
 | ||||
|                     if (siteUrl == treatedUrl) { | ||||
|                         result.site = site; | ||||
|                     } | ||||
| 
 | ||||
|                     return result; | ||||
|                 }); | ||||
|             } | ||||
|         const result: {site?: CoreSite; siteIds: string[]} = { | ||||
|             siteIds, | ||||
|         }; | ||||
| 
 | ||||
|         if (!siteIds.length) { | ||||
|             return result; | ||||
|         }); | ||||
|         } | ||||
| 
 | ||||
|         // If more than one site is returned it usually means there are different users stored. Use any of them.
 | ||||
|         const site = await this.getSite(siteIds[0]); | ||||
| 
 | ||||
|         const siteUrl = CoreTextUtils.instance.removeEndingSlash( | ||||
|             CoreUrlUtils.instance.removeProtocolAndWWW(site.getURL()), | ||||
|         ); | ||||
|         const treatedUrl = CoreTextUtils.instance.removeEndingSlash(CoreUrlUtils.instance.removeProtocolAndWWW(url)); | ||||
| 
 | ||||
|         if (siteUrl == treatedUrl) { | ||||
|             result.site = site; | ||||
|         } | ||||
| 
 | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -1775,12 +1815,12 @@ export class CoreSitesProvider { | ||||
|      * @return Name of the site schemas. | ||||
|      */ | ||||
|     getSiteTableSchemasToClear(site: CoreSite): string[] { | ||||
|         let reset = []; | ||||
|         let reset: string[] = []; | ||||
|         for (const name in this.siteSchemas) { | ||||
|             const schema = this.siteSchemas[name]; | ||||
| 
 | ||||
|             if (schema.canBeCleared && (!schema.siteId || site.getId() == schema.siteId)) { | ||||
|                 reset = reset.concat(this.siteSchemas[name].canBeCleared); | ||||
|                 reset = reset.concat(schema.canBeCleared); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
| @ -1900,17 +1940,17 @@ export type CoreSiteBasicInfo = { | ||||
|     /** | ||||
|      * User's full name. | ||||
|      */ | ||||
|     fullName: string; | ||||
|     fullName?: string; | ||||
| 
 | ||||
|     /** | ||||
|      * Site's name. | ||||
|      */ | ||||
|     siteName: string; | ||||
|     siteName?: string; | ||||
| 
 | ||||
|     /** | ||||
|      * User's avatar. | ||||
|      */ | ||||
|     avatar: string; | ||||
|     avatar?: string; | ||||
| 
 | ||||
|     /** | ||||
|      * Badge to display in the site. | ||||
|  | ||||
| @ -30,6 +30,7 @@ import { CoreConstants } from '@core/constants'; | ||||
| import { CoreIonLoadingElement } from '@classes/ion-loading'; | ||||
| import { CoreCanceledError } from '@classes/errors/cancelederror'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import { CoreSilentError } from '@classes/errors/silenterror'; | ||||
| 
 | ||||
| import { makeSingleton, Translate, AlertController, LoadingController, ToastController } from '@singletons/core.singletons'; | ||||
| import { CoreLogger } from '@singletons/logger'; | ||||
| @ -40,14 +41,15 @@ import { CoreLogger } from '@singletons/logger'; | ||||
| @Injectable() | ||||
| export class CoreDomUtilsProvider { | ||||
| 
 | ||||
|     protected readonly INSTANCE_ID_ATTR_NAME = 'core-instance-id'; | ||||
| 
 | ||||
|     // List of input types that support keyboard.
 | ||||
|     protected readonly INPUT_SUPPORT_KEYBOARD: string[] = ['date', 'datetime', 'datetime-local', 'email', 'month', 'number', | ||||
|         'password', 'search', 'tel', 'text', 'time', 'url', 'week']; | ||||
|     protected readonly INSTANCE_ID_ATTR_NAME: string = 'core-instance-id'; | ||||
| 
 | ||||
|     protected template: HTMLTemplateElement = document.createElement('template'); // A template element to convert HTML to element.
 | ||||
| 
 | ||||
|     protected matchesFn: string; // Name of the "matches" function to use when simulating a closest call.
 | ||||
|     protected matchesFunctionName?: string; // Name of the "matches" function to use when simulating a closest call.
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     protected instances: {[id: string]: any} = {}; // Store component/directive instances by id.
 | ||||
|     protected lastInstanceId = 0; | ||||
| @ -58,10 +60,17 @@ export class CoreDomUtilsProvider { | ||||
|     constructor(protected domSanitizer: DomSanitizer) { | ||||
|         this.logger = CoreLogger.getInstance('CoreDomUtilsProvider'); | ||||
| 
 | ||||
|         this.init(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Init some properties. | ||||
|      */ | ||||
|     protected async init(): Promise<void> { | ||||
|         // Check if debug messages should be displayed.
 | ||||
|         CoreConfig.instance.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { | ||||
|             this.debugDisplay = !!debugDisplay; | ||||
|         }); | ||||
|         const debugDisplay = await CoreConfig.instance.get<number>(CoreConstants.SETTINGS_DEBUG_DISPLAY, 0); | ||||
| 
 | ||||
|         this.debugDisplay = debugDisplay != 0; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -73,17 +82,21 @@ export class CoreDomUtilsProvider { | ||||
|      * @param selector Selector to search. | ||||
|      * @return Closest ancestor. | ||||
|      */ | ||||
|     closest(element: Element, selector: string): Element { | ||||
|     closest(element: Element | undefined | null, selector: string): Element | null { | ||||
|         if (!element) { | ||||
|             return null; | ||||
|         } | ||||
|      | ||||
|         // Try to use closest if the browser supports it.
 | ||||
|         if (typeof element.closest == 'function') { | ||||
|             return element.closest(selector); | ||||
|         } | ||||
| 
 | ||||
|         if (!this.matchesFn) { | ||||
|         if (!this.matchesFunctionName) { | ||||
|             // Find the matches function supported by the browser.
 | ||||
|             ['matches', 'webkitMatchesSelector', 'mozMatchesSelector', 'msMatchesSelector', 'oMatchesSelector'].some((fn) => { | ||||
|                 if (typeof document.body[fn] == 'function') { | ||||
|                     this.matchesFn = fn; | ||||
|                     this.matchesFunctionName = fn; | ||||
| 
 | ||||
|                     return true; | ||||
|                 } | ||||
| @ -91,18 +104,22 @@ export class CoreDomUtilsProvider { | ||||
|                 return false; | ||||
|             }); | ||||
| 
 | ||||
|             if (!this.matchesFn) { | ||||
|                 return; | ||||
|             if (!this.matchesFunctionName) { | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Traverse parents.
 | ||||
|         while (element) { | ||||
|             if (element[this.matchesFn](selector)) { | ||||
|                 return element; | ||||
|         let elementToTreat: Element | null = element; | ||||
| 
 | ||||
|         while (elementToTreat) { | ||||
|             if (elementToTreat[this.matchesFunctionName](selector)) { | ||||
|                 return elementToTreat; | ||||
|             } | ||||
|             element = element.parentElement; | ||||
|             elementToTreat = elementToTreat.parentElement; | ||||
|         } | ||||
| 
 | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -116,7 +133,7 @@ export class CoreDomUtilsProvider { | ||||
|      * @param alwaysConfirm True to show a confirm even if the size isn't high, false otherwise. | ||||
|      * @return Promise resolved when the user confirms or if no confirm needed. | ||||
|      */ | ||||
|     confirmDownloadSize( | ||||
|     async confirmDownloadSize( | ||||
|         size: {size: number; total: boolean}, | ||||
|         message?: string, | ||||
|         unknownMessage?: string, | ||||
| @ -126,73 +143,88 @@ export class CoreDomUtilsProvider { | ||||
|     ): Promise<void> { | ||||
|         const readableSize = CoreTextUtils.instance.bytesToSize(size.size, 2); | ||||
| 
 | ||||
|         const getAvailableBytes = new Promise((resolve): void => { | ||||
|         const getAvailableBytes = async (): Promise<number | null> => { | ||||
|             if (CoreApp.instance.isDesktop()) { | ||||
|                 // Free space calculation is not supported on desktop.
 | ||||
|                 resolve(null); | ||||
|             } else { | ||||
|                 CoreFile.instance.calculateFreeSpace().then((availableBytes) => { | ||||
|                     if (CoreApp.instance.isAndroid()) { | ||||
|                         return availableBytes; | ||||
|                     } else { | ||||
|                         // Space calculation is not accurate on iOS, but it gets more accurate when space is lower.
 | ||||
|                         // We'll only use it when space is <500MB, or we're downloading more than twice the reported space.
 | ||||
|                         if (availableBytes < CoreConstants.IOS_FREE_SPACE_THRESHOLD || size.size > availableBytes / 2) { | ||||
|                             return availableBytes; | ||||
|                         } else { | ||||
|                             return null; | ||||
|                         } | ||||
|                     } | ||||
|                 }).then((availableBytes) => { | ||||
|                     resolve(availableBytes); | ||||
|                 }); | ||||
|                 return null; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         const getAvailableSpace = getAvailableBytes.then((availableBytes: number) => { | ||||
|             const availableBytes = await CoreFile.instance.calculateFreeSpace(); | ||||
| 
 | ||||
|             if (CoreApp.instance.isAndroid()) { | ||||
|                 return availableBytes; | ||||
|             } else { | ||||
|                 // Space calculation is not accurate on iOS, but it gets more accurate when space is lower.
 | ||||
|                 // We'll only use it when space is <500MB, or we're downloading more than twice the reported space.
 | ||||
|                 if (availableBytes < CoreConstants.IOS_FREE_SPACE_THRESHOLD || size.size > availableBytes / 2) { | ||||
|                     return availableBytes; | ||||
|                 } else { | ||||
|                     return null; | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         const getAvailableSpace = (availableBytes: number | null): string => { | ||||
|             if (availableBytes === null) { | ||||
|                 return ''; | ||||
|             } else { | ||||
|                 const availableSize = CoreTextUtils.instance.bytesToSize(availableBytes, 2); | ||||
| 
 | ||||
|                 if (CoreApp.instance.isAndroid() && size.size > availableBytes - CoreConstants.MINIMUM_FREE_SPACE) { | ||||
|                     return Promise.reject(new CoreError(Translate.instance.instant('core.course.insufficientavailablespace', | ||||
|                         { size: readableSize }))); | ||||
|                     throw new CoreError( | ||||
|                         Translate.instance.instant( | ||||
|                             'core.course.insufficientavailablespace', | ||||
|                             { size: readableSize }, | ||||
|                         ), | ||||
|                     ); | ||||
|                 } | ||||
| 
 | ||||
|                 return Translate.instance.instant('core.course.availablespace', { available: availableSize }); | ||||
|             } | ||||
|         }); | ||||
|         }; | ||||
| 
 | ||||
|         return getAvailableSpace.then((availableSpace) => { | ||||
|             wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; | ||||
|             limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; | ||||
|         const availableBytes = await getAvailableBytes(); | ||||
| 
 | ||||
|             let wifiPrefix = ''; | ||||
|             if (CoreApp.instance.isNetworkAccessLimited()) { | ||||
|                 wifiPrefix = Translate.instance.instant('core.course.confirmlimiteddownload'); | ||||
|             } | ||||
|         const availableSpace = getAvailableSpace(availableBytes); | ||||
| 
 | ||||
|             if (size.size < 0 || (size.size == 0 && !size.total)) { | ||||
|                 // Seems size was unable to be calculated. Show a warning.
 | ||||
|                 unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; | ||||
|         wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; | ||||
|         limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; | ||||
| 
 | ||||
|                 return this.showConfirm(wifiPrefix + Translate.instance.instant( | ||||
|                     unknownMessage, { availableSpace: availableSpace })); | ||||
|             } else if (!size.total) { | ||||
|                 // Filesize is only partial.
 | ||||
|         let wifiPrefix = ''; | ||||
|         if (CoreApp.instance.isNetworkAccessLimited()) { | ||||
|             wifiPrefix = Translate.instance.instant('core.course.confirmlimiteddownload'); | ||||
|         } | ||||
| 
 | ||||
|                 return this.showConfirm(wifiPrefix + Translate.instance.instant('core.course.confirmpartialdownloadsize', | ||||
|                     { size: readableSize, availableSpace: availableSpace })); | ||||
|             } else if (alwaysConfirm || size.size >= wifiThreshold || | ||||
|         if (size.size < 0 || (size.size == 0 && !size.total)) { | ||||
|             // Seems size was unable to be calculated. Show a warning.
 | ||||
|             unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; | ||||
| 
 | ||||
|             return this.showConfirm( | ||||
|                 wifiPrefix + Translate.instance.instant( | ||||
|                     unknownMessage, | ||||
|                     { availableSpace: availableSpace }, | ||||
|                 ), | ||||
|             ); | ||||
|         } else if (!size.total) { | ||||
|             // Filesize is only partial.
 | ||||
| 
 | ||||
|             return this.showConfirm( | ||||
|                 wifiPrefix + Translate.instance.instant( | ||||
|                     'core.course.confirmpartialdownloadsize', | ||||
|                     { size: readableSize, availableSpace: availableSpace }, | ||||
|                 ), | ||||
|             ); | ||||
|         } else if (alwaysConfirm || size.size >= wifiThreshold || | ||||
|                 (CoreApp.instance.isNetworkAccessLimited() && size.size >= limitedThreshold)) { | ||||
|                 message = message || (size.size === 0 ? 'core.course.confirmdownloadzerosize' : 'core.course.confirmdownload'); | ||||
|             message = message || (size.size === 0 ? 'core.course.confirmdownloadzerosize' : 'core.course.confirmdownload'); | ||||
| 
 | ||||
|                 return this.showConfirm(wifiPrefix + Translate.instance.instant(message, | ||||
|                     { size: readableSize, availableSpace: availableSpace })); | ||||
|             } | ||||
| 
 | ||||
|             return Promise.resolve(); | ||||
|         }); | ||||
|             return this.showConfirm( | ||||
|                 wifiPrefix + Translate.instance.instant( | ||||
|                     message, | ||||
|                     { size: readableSize, availableSpace: availableSpace }, | ||||
|                 ), | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -255,11 +287,10 @@ export class CoreDomUtilsProvider { | ||||
|         this.logger.error('The function extractDownloadableFilesFromHtml has been moved to CoreFilepoolProvider.' + | ||||
|                 ' Please use that function instead of this one.'); | ||||
| 
 | ||||
|         const urls = []; | ||||
|         const urls: string[] = []; | ||||
| 
 | ||||
|         const element = this.convertToElement(html); | ||||
|         const elements: (HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | | ||||
|             HTMLTrackElement)[] = Array.from(element.querySelectorAll('a, img, audio, video, source, track')); | ||||
|         const elements: AnchorOrMediaElement[] = Array.from(element.querySelectorAll('a, img, audio, video, source, track')); | ||||
| 
 | ||||
|         for (let i = 0; i < elements.length; i++) { | ||||
|             const element = elements[i]; | ||||
| @ -271,7 +302,7 @@ export class CoreDomUtilsProvider { | ||||
| 
 | ||||
|             // Treat video poster.
 | ||||
|             if (element.tagName == 'VIDEO' && element.getAttribute('poster')) { | ||||
|                 url = element.getAttribute('poster'); | ||||
|                 url = element.getAttribute('poster') || ''; | ||||
|                 if (url && CoreUrlUtils.instance.isDownloadableUrl(url) && urls.indexOf(url) == -1) { | ||||
|                     urls.push(url); | ||||
|                 } | ||||
| @ -305,7 +336,7 @@ export class CoreDomUtilsProvider { | ||||
|      */ | ||||
|     extractUrlsFromCSS(code: string): string[] { | ||||
|         // First of all, search all the url(...) occurrences that don't include "data:".
 | ||||
|         const urls = []; | ||||
|         const urls: string[] = []; | ||||
|         const matches = code.match(/url\(\s*["']?(?!data:)([^)]+)\)/igm); | ||||
| 
 | ||||
|         if (!matches) { | ||||
| @ -394,7 +425,7 @@ export class CoreDomUtilsProvider { | ||||
|      * @param selector Selector to search. | ||||
|      * @return Selection contents. Undefined if not found. | ||||
|      */ | ||||
|     getContentsOfElement(element: HTMLElement, selector: string): string { | ||||
|     getContentsOfElement(element: HTMLElement, selector: string): string | undefined { | ||||
|         if (element) { | ||||
|             const selected = element.querySelector(selector); | ||||
|             if (selected) { | ||||
| @ -447,7 +478,7 @@ export class CoreDomUtilsProvider { | ||||
|      * @param attribute Attribute to get. | ||||
|      * @return Attribute value. | ||||
|      */ | ||||
|     getHTMLElementAttribute(html: string, attribute: string): string { | ||||
|     getHTMLElementAttribute(html: string, attribute: string): string | null { | ||||
|         return this.convertToElement(html).children[0].getAttribute(attribute); | ||||
|     } | ||||
| 
 | ||||
| @ -584,8 +615,8 @@ export class CoreDomUtilsProvider { | ||||
|      * @param positionParentClass Parent Class where to stop calculating the position. Default inner-scroll. | ||||
|      * @return positionLeft, positionTop of the element relative to. | ||||
|      */ | ||||
|     getElementXY(container: HTMLElement, selector?: string, positionParentClass?: string): number[] { | ||||
|         let element: HTMLElement = <HTMLElement> (selector ? container.querySelector(selector) : container); | ||||
|     getElementXY(container: HTMLElement, selector?: string, positionParentClass?: string): number[] | null { | ||||
|         let element: HTMLElement | null = <HTMLElement> (selector ? container.querySelector(selector) : container); | ||||
|         let positionTop = 0; | ||||
|         let positionLeft = 0; | ||||
| 
 | ||||
| @ -645,9 +676,9 @@ export class CoreDomUtilsProvider { | ||||
|      * @param needsTranslate Whether the error needs to be translated. | ||||
|      * @return Error message, null if no error should be displayed. | ||||
|      */ | ||||
|     getErrorMessage(error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean): string { | ||||
|     getErrorMessage(error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean): string | null { | ||||
|         let extraInfo = ''; | ||||
|         let errorMessage: string; | ||||
|         let errorMessage: string | undefined; | ||||
| 
 | ||||
|         if (typeof error == 'object') { | ||||
|             if (this.debugDisplay) { | ||||
| @ -657,19 +688,21 @@ export class CoreDomUtilsProvider { | ||||
|                 } | ||||
|                 if ('backtrace' in error && error.backtrace) { | ||||
|                     extraInfo += '<br><br>' + CoreTextUtils.instance.replaceNewLines( | ||||
|                         CoreTextUtils.instance.escapeHTML(error.backtrace, false), '<br>'); | ||||
|                         CoreTextUtils.instance.escapeHTML(error.backtrace, false), | ||||
|                         '<br>', | ||||
|                     ); | ||||
|                 } | ||||
| 
 | ||||
|                 // eslint-disable-next-line no-console
 | ||||
|                 console.error(error); | ||||
|             } | ||||
| 
 | ||||
|             // We received an object instead of a string. Search for common properties.
 | ||||
|             if (this.isCanceledError(error)) { | ||||
|                 // It's a canceled error, don't display an error.
 | ||||
|             if (this.isSilentError(error)) { | ||||
|                 // It's a silent error, don't display an error.
 | ||||
|                 return null; | ||||
|             } | ||||
| 
 | ||||
|             // We received an object instead of a string. Search for common properties.
 | ||||
|             errorMessage = CoreTextUtils.instance.getErrorMessageFromError(error); | ||||
|             if (!errorMessage) { | ||||
|                 // No common properties found, just stringify it.
 | ||||
| @ -712,7 +745,7 @@ export class CoreDomUtilsProvider { | ||||
|     getInstanceByElement(element: Element): any { | ||||
|         const id = element.getAttribute(this.INSTANCE_ID_ATTR_NAME); | ||||
| 
 | ||||
|         return this.instances[id]; | ||||
|         return id && this.instances[id]; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -725,6 +758,16 @@ export class CoreDomUtilsProvider { | ||||
|         return error instanceof CoreCanceledError; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether an error is an error caused because the user canceled a showConfirm. | ||||
|      * | ||||
|      * @param error Error to check. | ||||
|      * @return Whether it's a canceled error. | ||||
|      */ | ||||
|     isSilentError(error: CoreError | CoreTextErrorObject | string): boolean { | ||||
|         return error instanceof CoreSilentError; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Wait an element to exists using the findFunction. | ||||
|      * | ||||
| @ -898,7 +941,7 @@ export class CoreDomUtilsProvider { | ||||
|      */ | ||||
|     removeInstanceByElement(element: Element): void { | ||||
|         const id = element.getAttribute(this.INSTANCE_ID_ATTR_NAME); | ||||
|         delete this.instances[id]; | ||||
|         id && delete this.instances[id]; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -946,7 +989,8 @@ export class CoreDomUtilsProvider { | ||||
|         // Treat elements with src (img, audio, video, ...).
 | ||||
|         const media = Array.from(element.querySelectorAll('img, video, audio, source, track')); | ||||
|         media.forEach((media: HTMLElement) => { | ||||
|             let newSrc = paths[CoreTextUtils.instance.decodeURIComponent(media.getAttribute('src'))]; | ||||
|             const currentSrc = media.getAttribute('src'); | ||||
|             const newSrc = currentSrc ? paths[CoreTextUtils.instance.decodeURIComponent(currentSrc)] : undefined; | ||||
| 
 | ||||
|             if (typeof newSrc != 'undefined') { | ||||
|                 media.setAttribute('src', newSrc); | ||||
| @ -954,9 +998,10 @@ export class CoreDomUtilsProvider { | ||||
| 
 | ||||
|             // Treat video posters.
 | ||||
|             if (media.tagName == 'VIDEO' && media.getAttribute('poster')) { | ||||
|                 newSrc = paths[CoreTextUtils.instance.decodeURIComponent(media.getAttribute('poster'))]; | ||||
|                 if (typeof newSrc !== 'undefined') { | ||||
|                     media.setAttribute('poster', newSrc); | ||||
|                 const currentPoster = media.getAttribute('poster'); | ||||
|                 const newPoster = paths[CoreTextUtils.instance.decodeURIComponent(currentPoster!)]; | ||||
|                 if (typeof newPoster !== 'undefined') { | ||||
|                     media.setAttribute('poster', newPoster); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
| @ -964,14 +1009,14 @@ export class CoreDomUtilsProvider { | ||||
|         // Now treat links.
 | ||||
|         const anchors = Array.from(element.querySelectorAll('a')); | ||||
|         anchors.forEach((anchor: HTMLElement) => { | ||||
|             const href = CoreTextUtils.instance.decodeURIComponent(anchor.getAttribute('href')); | ||||
|             const newUrl = paths[href]; | ||||
|             const currentHref = anchor.getAttribute('href'); | ||||
|             const newHref = currentHref ? paths[CoreTextUtils.instance.decodeURIComponent(currentHref)] : undefined; | ||||
| 
 | ||||
|             if (typeof newUrl != 'undefined') { | ||||
|                 anchor.setAttribute('href', newUrl); | ||||
|             if (typeof newHref != 'undefined') { | ||||
|                 anchor.setAttribute('href', newHref); | ||||
| 
 | ||||
|                 if (typeof anchorFn == 'function') { | ||||
|                     anchorFn(anchor, href); | ||||
|                     anchorFn(anchor, newHref); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
| @ -990,7 +1035,7 @@ export class CoreDomUtilsProvider { | ||||
|      * @deprecated since 3.9.5. Use directly the IonContent class. | ||||
|      */ | ||||
|     scrollTo(content: IonContent, x: number, y: number, duration?: number): Promise<void> { | ||||
|         return content?.scrollByPoint(x, y, duration); | ||||
|         return content?.scrollByPoint(x, y, duration || 0); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -1080,7 +1125,7 @@ export class CoreDomUtilsProvider { | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|         content?.scrollByPoint(position[0], position[1], duration); | ||||
|         content?.scrollByPoint(position[0], position[1], duration || 0); | ||||
| 
 | ||||
|         return true; | ||||
|     } | ||||
| @ -1108,7 +1153,7 @@ export class CoreDomUtilsProvider { | ||||
|                 return false; | ||||
|             } | ||||
| 
 | ||||
|             content?.scrollByPoint(position[0], position[1], duration); | ||||
|             content?.scrollByPoint(position[0], position[1], duration || 0); | ||||
| 
 | ||||
|             return true; | ||||
|         } catch (error) { | ||||
| @ -1186,31 +1231,36 @@ export class CoreDomUtilsProvider { | ||||
| 
 | ||||
|         const alert = await AlertController.instance.create(options); | ||||
| 
 | ||||
|         // eslint-disable-next-line promise/catch-or-return
 | ||||
|         alert.present().then(() => { | ||||
|             if (hasHTMLTags) { | ||||
|                 // Treat all anchors so they don't override the app.
 | ||||
|                 const alertMessageEl: HTMLElement = alert.querySelector('.alert-message'); | ||||
|                 this.treatAnchors(alertMessageEl); | ||||
|                 const alertMessageEl: HTMLElement | null = alert.querySelector('.alert-message'); | ||||
|                 alertMessageEl && this.treatAnchors(alertMessageEl); | ||||
|             } | ||||
| 
 | ||||
|             return; | ||||
|         }); | ||||
| 
 | ||||
|         // Store the alert and remove it when dismissed.
 | ||||
|         this.displayedAlerts[alertId] = alert; | ||||
| 
 | ||||
|         // // Set the callbacks to trigger an observable event.
 | ||||
|         // eslint-disable-next-line promise/catch-or-return, promise/always-return
 | ||||
|         alert.onDidDismiss().then(() => { | ||||
|             delete this.displayedAlerts[alertId]; | ||||
|         }); | ||||
| 
 | ||||
|         if (autocloseTime > 0) { | ||||
|         if (autocloseTime && autocloseTime > 0) { | ||||
|             setTimeout(async () => { | ||||
|                 await alert.dismiss(); | ||||
| 
 | ||||
|                 if (options.buttons) { | ||||
|                     // Execute dismiss function if any.
 | ||||
|                     const cancelButton = <AlertButton> options.buttons.find((button) => typeof button != 'string' && | ||||
|                         typeof button.handler != 'undefined' && button.role == 'cancel'); | ||||
|                     cancelButton?.handler(null); | ||||
|                     const cancelButton = <AlertButton> options.buttons.find( | ||||
|                         (button) => typeof button != 'string' && typeof button.handler != 'undefined' && button.role == 'cancel', | ||||
|                     ); | ||||
|                     cancelButton.handler?.(null); | ||||
|                 } | ||||
|             }, autocloseTime); | ||||
|         } | ||||
| @ -1248,8 +1298,13 @@ export class CoreDomUtilsProvider { | ||||
|         translateArgs: Record<string, string> = {}, | ||||
|         options?: AlertOptions, | ||||
|     ): Promise<void> { | ||||
|         return this.showConfirm(Translate.instance.instant(translateMessage, translateArgs), undefined, | ||||
|             Translate.instance.instant('core.delete'), undefined, options); | ||||
|         return this.showConfirm( | ||||
|             Translate.instance.instant(translateMessage, translateArgs), | ||||
|             undefined, | ||||
|             Translate.instance.instant('core.delete'), | ||||
|             undefined, | ||||
|             options, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -1304,6 +1359,11 @@ export class CoreDomUtilsProvider { | ||||
|         needsTranslate?: boolean, | ||||
|         autocloseTime?: number, | ||||
|     ): Promise<HTMLIonAlertElement | null> { | ||||
|         if (this.isCanceledError(error)) { | ||||
|             // It's a canceled error, don't display an error.
 | ||||
|             return Promise.resolve(null); | ||||
|         } | ||||
| 
 | ||||
|         const message = this.getErrorMessage(error, needsTranslate); | ||||
| 
 | ||||
|         if (message === null) { | ||||
| @ -1334,7 +1394,7 @@ export class CoreDomUtilsProvider { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         let errorMessage = error; | ||||
|         let errorMessage = error || undefined; | ||||
| 
 | ||||
|         if (error && typeof error != 'string') { | ||||
|             errorMessage = CoreTextUtils.instance.getErrorMessageFromError(error); | ||||
| @ -1423,8 +1483,8 @@ export class CoreDomUtilsProvider { | ||||
|         const isDevice = CoreApp.instance.isAndroid() || CoreApp.instance.isIOS(); | ||||
|         if (!isDevice) { | ||||
|             // Treat all anchors so they don't override the app.
 | ||||
|             const alertMessageEl: HTMLElement = alert.querySelector('.alert-message'); | ||||
|             this.treatAnchors(alertMessageEl); | ||||
|             const alertMessageEl: HTMLElement | null = alert.querySelector('.alert-message'); | ||||
|             alertMessageEl && this.treatAnchors(alertMessageEl); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -1443,8 +1503,7 @@ export class CoreDomUtilsProvider { | ||||
|         header?: string, | ||||
|         placeholder?: string, | ||||
|         type: TextFieldTypes | 'checkbox' | 'radio' | 'textarea' = 'password', | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     ): Promise<any> { | ||||
|     ): Promise<any> { // eslint-disable-line @typescript-eslint/no-explicit-any
 | ||||
|         return new Promise((resolve, reject) => { | ||||
|             placeholder = placeholder ?? Translate.instance.instant('core.login.password'); | ||||
| 
 | ||||
| @ -1532,7 +1591,7 @@ export class CoreDomUtilsProvider { | ||||
|      * @param instance The instance to store. | ||||
|      * @return ID to identify the instance. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     storeInstanceByElement(element: Element, instance: any): string { | ||||
|         const id = String(this.lastInstanceId++); | ||||
| 
 | ||||
| @ -1602,7 +1661,7 @@ export class CoreDomUtilsProvider { | ||||
|      * @param componentId An ID to use in conjunction with the component. | ||||
|      * @param fullScreen Whether the modal should be full screen. | ||||
|      */ | ||||
|     viewImage(image: string, title?: string, component?: string, componentId?: string | number, fullScreen?: boolean): void { | ||||
|     viewImage(image: string, title?: string | null, component?: string, componentId?: string | number, fullScreen?: boolean): void { | ||||
|         // @todo
 | ||||
|     } | ||||
| 
 | ||||
| @ -1614,7 +1673,7 @@ export class CoreDomUtilsProvider { | ||||
|      */ | ||||
|     waitForImages(element: HTMLElement): Promise<boolean> { | ||||
|         const imgs = Array.from(element.querySelectorAll('img')); | ||||
|         const promises = []; | ||||
|         const promises: Promise<void>[] = []; | ||||
|         let hasImgToLoad = false; | ||||
| 
 | ||||
|         imgs.forEach((img) => { | ||||
| @ -1646,7 +1705,7 @@ export class CoreDomUtilsProvider { | ||||
|      */ | ||||
|     wrapElement(el: HTMLElement, wrapper: HTMLElement): void { | ||||
|         // Insert the wrapper before the element.
 | ||||
|         el.parentNode.insertBefore(wrapper, el); | ||||
|         el.parentNode?.insertBefore(wrapper, el); | ||||
|         // Now move the element into the wrapper.
 | ||||
|         wrapper.appendChild(el); | ||||
|     } | ||||
| @ -1675,7 +1734,7 @@ export class CoreDomUtilsProvider { | ||||
|      * @param online Whether the action was done in offline or not. | ||||
|      * @param siteId The site affected. If not provided, no site affected. | ||||
|      */ | ||||
|     triggerFormSubmittedEvent(formRef: ElementRef, online?: boolean, siteId?: string): void { | ||||
|     triggerFormSubmittedEvent(formRef: ElementRef | undefined, online?: boolean, siteId?: string): void { | ||||
|         if (!formRef) { | ||||
|             return; | ||||
|         } | ||||
| @ -1690,3 +1749,6 @@ export class CoreDomUtilsProvider { | ||||
| } | ||||
| 
 | ||||
| export class CoreDomUtils extends makeSingleton(CoreDomUtilsProvider) {} | ||||
| 
 | ||||
| type AnchorOrMediaElement = | ||||
|     HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | HTMLTrackElement; | ||||
|  | ||||
| @ -423,7 +423,7 @@ export class CoreIframeUtilsProvider { | ||||
|             if (!CoreSites.instance.isLoggedIn()) { | ||||
|                 CoreUtils.instance.openInBrowser(link.href); | ||||
|             } else { | ||||
|                 await CoreSites.instance.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(link.href); | ||||
|                 await CoreSites.instance.getCurrentSite()!.openInBrowserWithAutoLoginIfSameSite(link.href); | ||||
|             } | ||||
|         } else if (link.target == '_parent' || link.target == '_top' || link.target == '_blank') { | ||||
|             // Opening links with _parent, _top or _blank can break the app. We'll open it in InAppBrowser.
 | ||||
|  | ||||
| @ -401,7 +401,7 @@ export class CoreMimetypeUtilsProvider { | ||||
|      * @param capitalise If true, capitalises first character of result. | ||||
|      * @return Type description. | ||||
|      */ | ||||
|     getMimetypeDescription(obj: FileEntry | { filename: string; mimetype: string } | string, capitalise?: boolean): string { | ||||
|     getMimetypeDescription(obj: FileEntry | CoreWSExternalFile | string, capitalise?: boolean): string { | ||||
|         const langPrefix = 'assets.mimetypes.'; | ||||
|         let filename: string | undefined = ''; | ||||
|         let mimetype: string | undefined = ''; | ||||
|  | ||||
| @ -417,7 +417,7 @@ export class CoreTextUtilsProvider { | ||||
|      * @param doubleEncode If false, it will not convert existing html entities. Defaults to true. | ||||
|      * @return Escaped text. | ||||
|      */ | ||||
|     escapeHTML(text: string | number, doubleEncode: boolean = true): string { | ||||
|     escapeHTML(text?: string | number | null, doubleEncode: boolean = true): string { | ||||
|         if (typeof text == 'undefined' || text === null || (typeof text == 'number' && isNaN(text))) { | ||||
|             return ''; | ||||
|         } else if (typeof text != 'string') { | ||||
| @ -670,7 +670,7 @@ export class CoreTextUtilsProvider { | ||||
|      * @param text Text to treat. | ||||
|      * @return Treated text. | ||||
|      */ | ||||
|     removeEndingSlash(text: string): string { | ||||
|     removeEndingSlash(text?: string): string { | ||||
|         if (!text) { | ||||
|             return ''; | ||||
|         } | ||||
|  | ||||
| @ -44,7 +44,7 @@ import { CoreAjaxWSError } from '@classes/errors/ajaxwserror'; | ||||
| export class CoreWSProvider { | ||||
| 
 | ||||
|     protected logger: CoreLogger; | ||||
|     protected mimeTypeCache: {[url: string]: string} = {}; // A "cache" to store file mimetypes to decrease HEAD requests.
 | ||||
|     protected mimeTypeCache: {[url: string]: string | null} = {}; // A "cache" to store file mimetypes to decrease HEAD requests.
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     protected ongoingCalls: {[queueItemId: string]: Promise<any>} = {}; | ||||
|     protected retryCalls: RetryCall[] = []; | ||||
| @ -53,11 +53,18 @@ export class CoreWSProvider { | ||||
|     constructor() { | ||||
|         this.logger = CoreLogger.getInstance('CoreWSProvider'); | ||||
| 
 | ||||
|         Platform.instance.ready().then(() => { | ||||
|             if (CoreApp.instance.isIOS()) { | ||||
|                 NativeHttp.instance.setHeader('*', 'User-Agent', navigator.userAgent); | ||||
|             } | ||||
|         }); | ||||
|         this.init(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize some data. | ||||
|      */ | ||||
|     protected async init(): Promise<void> { | ||||
|         await Platform.instance.ready(); | ||||
| 
 | ||||
|         if (CoreApp.instance.isIOS()) { | ||||
|             NativeHttp.instance.setHeader('*', 'User-Agent', navigator.userAgent); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -67,8 +74,7 @@ export class CoreWSProvider { | ||||
|      * @param siteUrl Complete site url to perform the call. | ||||
|      * @param ajaxData Arguments to pass to the method. | ||||
|      * @param preSets Extra settings and information. | ||||
|      * @return Deferred promise resolved with the response data in success and rejected with the error message | ||||
|      *         if it fails. | ||||
|      * @return Deferred promise resolved with the response data in success and rejected with the error if it fails. | ||||
|      */ | ||||
|     protected addToRetryQueue<T = unknown>(method: string, siteUrl: string, data: unknown, preSets: CoreWSPreSets): Promise<T> { | ||||
|         const call = { | ||||
| @ -94,9 +100,9 @@ export class CoreWSProvider { | ||||
|      */ | ||||
|     call<T = unknown>(method: string, data: unknown, preSets: CoreWSPreSets): Promise<T> { | ||||
|         if (!preSets) { | ||||
|             return Promise.reject(new CoreError(Translate.instance.instant('core.unexpectederror'))); | ||||
|             throw new CoreError(Translate.instance.instant('core.unexpectederror')); | ||||
|         } else if (!CoreApp.instance.isOnline()) { | ||||
|             return Promise.reject(new CoreError(Translate.instance.instant('core.networkerrormsg'))); | ||||
|             throw new CoreError(Translate.instance.instant('core.networkerrormsg')); | ||||
|         } | ||||
| 
 | ||||
|         preSets.typeExpected = preSets.typeExpected || 'object'; | ||||
| @ -113,9 +119,9 @@ export class CoreWSProvider { | ||||
|         if (this.retryCalls.length > 0) { | ||||
|             this.logger.warn('Calls locked, trying later...'); | ||||
| 
 | ||||
|             return this.addToRetryQueue<T>(method, siteUrl, data, preSets); | ||||
|             return this.addToRetryQueue<T>(method, siteUrl, dataToSend, preSets); | ||||
|         } else { | ||||
|             return this.performPost<T>(method, siteUrl, data, preSets); | ||||
|             return this.performPost<T>(method, siteUrl, dataToSend, preSets); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -126,10 +132,7 @@ export class CoreWSProvider { | ||||
|      * @param method The WebService method to be called. | ||||
|      * @param data Arguments to pass to the method. | ||||
|      * @param preSets Extra settings and information. Only some | ||||
|      * @return Promise resolved with the response data in success and rejected with an object containing: | ||||
|      *         - error: Error message. | ||||
|      *         - errorcode: Error code returned by the site (if any). | ||||
|      *         - available: 0 if unknown, 1 if available, -1 if not available. | ||||
|      * @return Promise resolved with the response data in success and rejected with CoreAjaxError. | ||||
|      */ | ||||
|     callAjax<T = unknown>(method: string, data: Record<string, unknown>, preSets: CoreWSAjaxPreSets): Promise<T> { | ||||
|         const cacheParams = { | ||||
| @ -155,7 +158,7 @@ export class CoreWSProvider { | ||||
|      * @param stripUnicode If Unicode long chars need to be stripped. | ||||
|      * @return The cleaned object or null if some strings becomes empty after stripping Unicode. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     convertValuesToString(data: any, stripUnicode?: boolean): any { | ||||
|         // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|         const result: any = Array.isArray(data) ? [] : {}; | ||||
| @ -232,8 +235,12 @@ export class CoreWSProvider { | ||||
|      * @param onProgress Function to call on progress. | ||||
|      * @return Promise resolved with the downloaded file. | ||||
|      */ | ||||
|     async downloadFile(url: string, path: string, addExtension?: boolean, onProgress?: (event: ProgressEvent) => void): | ||||
|             Promise<CoreWSDownloadedFileEntry> { | ||||
|     async downloadFile( | ||||
|         url: string, | ||||
|         path: string, | ||||
|         addExtension?: boolean, | ||||
|         onProgress?: (event: ProgressEvent) => void, | ||||
|     ): Promise<CoreWSDownloadedFileEntry> { | ||||
|         this.logger.debug('Downloading file', url, path, addExtension); | ||||
| 
 | ||||
|         if (!CoreApp.instance.isOnline()) { | ||||
| @ -249,7 +256,7 @@ export class CoreWSProvider { | ||||
|             const fileEntry = await CoreFile.instance.createFile(tmpPath); | ||||
| 
 | ||||
|             const transfer = FileTransfer.instance.create(); | ||||
|             transfer.onProgress(onProgress); | ||||
|             onProgress && transfer.onProgress(onProgress); | ||||
| 
 | ||||
|             // Download the file in the tmp file.
 | ||||
|             await transfer.download(url, fileEntry.toURL(), true); | ||||
| @ -257,7 +264,7 @@ export class CoreWSProvider { | ||||
|             let extension = ''; | ||||
| 
 | ||||
|             if (addExtension) { | ||||
|                 extension = CoreMimetypeUtils.instance.getFileExtension(path); | ||||
|                 extension = CoreMimetypeUtils.instance.getFileExtension(path) || ''; | ||||
| 
 | ||||
|                 // Google Drive extensions will be considered invalid since Moodle usually converts them.
 | ||||
|                 if (!extension || CoreArray.contains(['gdoc', 'gsheet', 'gslides', 'gdraw', 'php'], extension)) { | ||||
| @ -281,14 +288,15 @@ export class CoreWSProvider { | ||||
|             } | ||||
| 
 | ||||
|             // Move the file to the final location.
 | ||||
|             const movedEntry: CoreWSDownloadedFileEntry = await CoreFile.instance.moveFile(tmpPath, path); | ||||
|             const movedEntry = await CoreFile.instance.moveFile(tmpPath, path); | ||||
| 
 | ||||
|             // Save the extension.
 | ||||
|             movedEntry.extension = extension; | ||||
|             movedEntry.path = path; | ||||
|             this.logger.debug(`Success downloading file ${url} to ${path} with extension ${extension}`); | ||||
| 
 | ||||
|             return movedEntry; | ||||
|             // Also return the extension and path.
 | ||||
|             return <CoreWSDownloadedFileEntry> Object.assign(movedEntry, { | ||||
|                 extension: extension, | ||||
|                 path: path, | ||||
|             }); | ||||
|         } catch (error) { | ||||
|             this.logger.error(`Error downloading ${url} to ${path}`, error); | ||||
| 
 | ||||
| @ -303,7 +311,7 @@ export class CoreWSProvider { | ||||
|      * @param url Base URL of the HTTP request. | ||||
|      * @param params Params of the HTTP request. | ||||
|      */ | ||||
|     protected getPromiseHttp<T = unknown>(method: string, url: string, params?: Record<string, unknown>): Promise<T> { | ||||
|     protected getPromiseHttp<T = unknown>(method: string, url: string, params?: Record<string, unknown>): Promise<T> | undefined { | ||||
|         const queueItemId = this.getQueueItemId(method, url, params); | ||||
|         if (typeof this.ongoingCalls[queueItemId] != 'undefined') { | ||||
|             return this.ongoingCalls[queueItemId]; | ||||
| @ -317,12 +325,14 @@ export class CoreWSProvider { | ||||
|      * @param ignoreCache True to ignore cache, false otherwise. | ||||
|      * @return Promise resolved with the mimetype or '' if failure. | ||||
|      */ | ||||
|     getRemoteFileMimeType(url: string, ignoreCache?: boolean): Promise<string> { | ||||
|     async getRemoteFileMimeType(url: string, ignoreCache?: boolean): Promise<string> { | ||||
|         if (this.mimeTypeCache[url] && !ignoreCache) { | ||||
|             return Promise.resolve(this.mimeTypeCache[url]); | ||||
|             return this.mimeTypeCache[url]!; | ||||
|         } | ||||
| 
 | ||||
|         return this.performHead(url).then((response) => { | ||||
|         try { | ||||
|             const response = await this.performHead(url); | ||||
| 
 | ||||
|             let mimeType = response.headers.get('Content-Type'); | ||||
|             if (mimeType) { | ||||
|                 // Remove "parameters" like charset.
 | ||||
| @ -331,10 +341,10 @@ export class CoreWSProvider { | ||||
|             this.mimeTypeCache[url] = mimeType; | ||||
| 
 | ||||
|             return mimeType || ''; | ||||
|         }).catch(() => | ||||
|         } catch (error) { | ||||
|             // Error, resolve with empty mimetype.
 | ||||
|             '', | ||||
|         ); | ||||
|             return ''; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -345,17 +355,15 @@ export class CoreWSProvider { | ||||
|      */ | ||||
|     getRemoteFileSize(url: string): Promise<number> { | ||||
|         return this.performHead(url).then((response) => { | ||||
|             const size = parseInt(response.headers.get('Content-Length'), 10); | ||||
|             const contentLength = response.headers.get('Content-Length'); | ||||
|             const size = contentLength ? parseInt(contentLength, 10) : 0; | ||||
| 
 | ||||
|             if (size) { | ||||
|                 return size; | ||||
|             } | ||||
| 
 | ||||
|             return -1; | ||||
|         }).catch(() => | ||||
|             // Error, return -1.
 | ||||
|             -1, | ||||
|         ); | ||||
|         }).catch(() => -1); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -389,19 +397,16 @@ export class CoreWSProvider { | ||||
|      * @param method The WebService method to be called. | ||||
|      * @param data Arguments to pass to the method. | ||||
|      * @param preSets Extra settings and information. Only some | ||||
|      * @return Promise resolved with the response data in success and rejected with an object containing: | ||||
|      *         - error: Error message. | ||||
|      *         - errorcode: Error code returned by the site (if any). | ||||
|      *         - available: 0 if unknown, 1 if available, -1 if not available. | ||||
|      * @return Promise resolved with the response data in success and rejected with CoreAjaxError. | ||||
|      */ | ||||
|     protected performAjax<T = unknown>(method: string, data: Record<string, unknown>, preSets: CoreWSAjaxPreSets): Promise<T> { | ||||
|         // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|         let promise: Promise<HttpResponse<any>>; | ||||
| 
 | ||||
|         if (typeof preSets.siteUrl == 'undefined') { | ||||
|             return Promise.reject(new CoreAjaxError(Translate.instance.instant('core.unexpectederror'))); | ||||
|             throw new CoreAjaxError(Translate.instance.instant('core.unexpectederror')); | ||||
|         } else if (!CoreApp.instance.isOnline()) { | ||||
|             return Promise.reject(new CoreAjaxError(Translate.instance.instant('core.networkerrormsg'))); | ||||
|             throw new CoreAjaxError(Translate.instance.instant('core.networkerrormsg')); | ||||
|         } | ||||
| 
 | ||||
|         if (typeof preSets.responseExpected == 'undefined') { | ||||
| @ -446,23 +451,23 @@ export class CoreWSProvider { | ||||
| 
 | ||||
|             // Check if error. Ajax layer should always return an object (if error) or an array (if success).
 | ||||
|             if (!data || typeof data != 'object') { | ||||
|                 return Promise.reject(new CoreAjaxError(Translate.instance.instant('core.serverconnection'))); | ||||
|                 throw new CoreAjaxError(Translate.instance.instant('core.serverconnection')); | ||||
|             } else if (data.error) { | ||||
|                 return Promise.reject(new CoreAjaxWSError(data)); | ||||
|                 throw new CoreAjaxWSError(data); | ||||
|             } | ||||
| 
 | ||||
|             // Get the first response since only one request was done.
 | ||||
|             data = data[0]; | ||||
| 
 | ||||
|             if (data.error) { | ||||
|                 return Promise.reject(new CoreAjaxWSError(data.exception)); | ||||
|                 throw new CoreAjaxWSError(data.exception); | ||||
|             } | ||||
| 
 | ||||
|             return data.data; | ||||
|         }, (data) => { | ||||
|             const available = data.status == 404 ? -1 : 0; | ||||
| 
 | ||||
|             return Promise.reject(new CoreAjaxError(Translate.instance.instant('core.serverconnection'), available)); | ||||
|             throw new CoreAjaxError(Translate.instance.instant('core.serverconnection'), available); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| @ -522,7 +527,7 @@ export class CoreWSProvider { | ||||
|             } | ||||
| 
 | ||||
|             if (!data) { | ||||
|                 return Promise.reject(new CoreError(Translate.instance.instant('core.serverconnection'))); | ||||
|                 throw new CoreError(Translate.instance.instant('core.serverconnection')); | ||||
|             } else if (typeof data != preSets.typeExpected) { | ||||
|                 // If responseType is text an string will be returned, parse before returning.
 | ||||
|                 if (typeof data == 'string') { | ||||
| @ -531,7 +536,7 @@ export class CoreWSProvider { | ||||
|                         if (isNaN(data)) { | ||||
|                             this.logger.warn(`Response expected type "${preSets.typeExpected}" cannot be parsed to number`); | ||||
| 
 | ||||
|                             return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); | ||||
|                             throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); | ||||
|                         } | ||||
|                     } else if (preSets.typeExpected == 'boolean') { | ||||
|                         if (data === 'true') { | ||||
| @ -541,17 +546,17 @@ export class CoreWSProvider { | ||||
|                         } else { | ||||
|                             this.logger.warn(`Response expected type "${preSets.typeExpected}" is not true or false`); | ||||
| 
 | ||||
|                             return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); | ||||
|                             throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); | ||||
|                         } | ||||
|                     } else { | ||||
|                         this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`); | ||||
| 
 | ||||
|                         return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); | ||||
|                         throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); | ||||
|                     } | ||||
|                 } else { | ||||
|                     this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`); | ||||
| 
 | ||||
|                     return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); | ||||
|                     throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
| @ -561,11 +566,11 @@ export class CoreWSProvider { | ||||
|                     this.logger.error('Error calling WS', method, data); | ||||
|                 } | ||||
| 
 | ||||
|                 return Promise.reject(new CoreWSError(data)); | ||||
|                 throw new CoreWSError(data); | ||||
|             } | ||||
| 
 | ||||
|             if (typeof data.debuginfo != 'undefined') { | ||||
|                 return Promise.reject(new CoreError('Error. ' + data.message)); | ||||
|                 throw new CoreError('Error. ' + data.message); | ||||
|             } | ||||
| 
 | ||||
|             return data; | ||||
| @ -593,7 +598,7 @@ export class CoreWSProvider { | ||||
|                 return retryPromise; | ||||
|             } | ||||
| 
 | ||||
|             return Promise.reject(new CoreError(Translate.instance.instant('core.serverconnection'))); | ||||
|             throw new CoreError(Translate.instance.instant('core.serverconnection')); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| @ -606,7 +611,7 @@ export class CoreWSProvider { | ||||
|             const call = this.retryCalls.shift(); | ||||
|             // Add a delay between calls.
 | ||||
|             setTimeout(() => { | ||||
|                 call.deferred.resolve(this.performPost(call.method, call.siteUrl, call.data, call.preSets)); | ||||
|                 call!.deferred.resolve(this.performPost(call!.method, call!.siteUrl, call!.data, call!.preSets)); | ||||
|                 this.processRetryQueue(); | ||||
|             }, 200); | ||||
|         } else { | ||||
| @ -623,8 +628,12 @@ export class CoreWSProvider { | ||||
|      * @param params Params of the HTTP request. | ||||
|      * @return The promise saved. | ||||
|      */ | ||||
|     protected setPromiseHttp<T = unknown>(promise: Promise<T>, method: string, url: string, params?: Record<string, unknown>): | ||||
|             Promise<T> { | ||||
|     protected setPromiseHttp<T = unknown>( | ||||
|         promise: Promise<T>, | ||||
|         method: string, | ||||
|         url: string, | ||||
|         params?: Record<string, unknown>, | ||||
|     ): Promise<T> { | ||||
|         const queueItemId = this.getQueueItemId(method, url, params); | ||||
| 
 | ||||
|         this.ongoingCalls[queueItemId] = promise; | ||||
| @ -652,7 +661,7 @@ export class CoreWSProvider { | ||||
|      * @return Promise resolved with the response data in success and rejected with the error message if it fails. | ||||
|      * @return Request response. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     syncCall<T = unknown>(method: string, data: any, preSets: CoreWSPreSets): T { | ||||
|         if (!preSets) { | ||||
|             throw new CoreError(Translate.instance.instant('core.unexpectederror')); | ||||
| @ -728,22 +737,26 @@ export class CoreWSProvider { | ||||
|      * @param onProgress Function to call on progress. | ||||
|      * @return Promise resolved when uploaded. | ||||
|      */ | ||||
|     uploadFile<T = unknown>(filePath: string, options: CoreWSFileUploadOptions, preSets: CoreWSPreSets, | ||||
|             onProgress?: (event: ProgressEvent) => void): Promise<T> { | ||||
|     async uploadFile<T = unknown>( | ||||
|         filePath: string, | ||||
|         options: CoreWSFileUploadOptions, | ||||
|         preSets: CoreWSPreSets, | ||||
|         onProgress?: (event: ProgressEvent) => void, | ||||
|     ): Promise<T> { | ||||
|         this.logger.debug(`Trying to upload file: ${filePath}`); | ||||
| 
 | ||||
|         if (!filePath || !options || !preSets) { | ||||
|             return Promise.reject(new CoreError('Invalid options passed to upload file.')); | ||||
|             throw new CoreError('Invalid options passed to upload file.'); | ||||
|         } | ||||
| 
 | ||||
|         if (!CoreApp.instance.isOnline()) { | ||||
|             return Promise.reject(new CoreError(Translate.instance.instant('core.networkerrormsg'))); | ||||
|             throw new CoreError(Translate.instance.instant('core.networkerrormsg')); | ||||
|         } | ||||
| 
 | ||||
|         const uploadUrl = preSets.siteUrl + '/webservice/upload.php'; | ||||
|         const transfer = FileTransfer.instance.create(); | ||||
| 
 | ||||
|         transfer.onProgress(onProgress); | ||||
|         onProgress && transfer.onProgress(onProgress); | ||||
| 
 | ||||
|         options.httpMethod = 'POST'; | ||||
|         options.params = { | ||||
| @ -755,45 +768,51 @@ export class CoreWSProvider { | ||||
|         options.headers = {}; | ||||
|         options['Connection'] = 'close'; | ||||
| 
 | ||||
|         return transfer.upload(filePath, uploadUrl, options, true).then((success) => { | ||||
|             const data = CoreTextUtils.instance.parseJSON(success.response, null, | ||||
|                 this.logger.error.bind(this.logger, 'Error parsing response from upload', success.response)); | ||||
|         try { | ||||
|             const success = await transfer.upload(filePath, uploadUrl, options, true); | ||||
| 
 | ||||
|             // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|             const data = CoreTextUtils.instance.parseJSON<any>( | ||||
|                 success.response, | ||||
|                 null, | ||||
|                 this.logger.error.bind(this.logger, 'Error parsing response from upload', success.response), | ||||
|             ); | ||||
| 
 | ||||
|             if (data === null) { | ||||
|                 return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); | ||||
|                 throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); | ||||
|             } | ||||
| 
 | ||||
|             if (!data) { | ||||
|                 return Promise.reject(new CoreError(Translate.instance.instant('core.serverconnection'))); | ||||
|                 throw new CoreError(Translate.instance.instant('core.serverconnection')); | ||||
|             } else if (typeof data != 'object') { | ||||
|                 this.logger.warn('Upload file: Response of type "' + typeof data + '" received, expecting "object"'); | ||||
| 
 | ||||
|                 return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); | ||||
|                 throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); | ||||
|             } | ||||
| 
 | ||||
|             if (typeof data.exception !== 'undefined') { | ||||
|                 return Promise.reject(new CoreWSError(data)); | ||||
|                 throw new CoreWSError(data); | ||||
|             } else if (typeof data.error !== 'undefined') { | ||||
|                 return Promise.reject(new CoreWSError({ | ||||
|                 throw new CoreWSError({ | ||||
|                     errorcode: data.errortype, | ||||
|                     message: data.error, | ||||
|                 })); | ||||
|                 }); | ||||
|             } else if (data[0] && typeof data[0].error !== 'undefined') { | ||||
|                 return Promise.reject(new CoreWSError({ | ||||
|                 throw new CoreWSError({ | ||||
|                     errorcode: data[0].errortype, | ||||
|                     message: data[0].error, | ||||
|                 })); | ||||
|                 }); | ||||
|             } | ||||
| 
 | ||||
|             // We uploaded only 1 file, so we only return the first file returned.
 | ||||
|             this.logger.debug('Successfully uploaded file', filePath); | ||||
| 
 | ||||
|             return data[0]; | ||||
|         }).catch((error) => { | ||||
|         } catch (error) { | ||||
|             this.logger.error('Error while uploading file', filePath, error); | ||||
| 
 | ||||
|             return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); | ||||
|         }); | ||||
|             throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -842,7 +861,7 @@ export class CoreWSProvider { | ||||
| 
 | ||||
|                 return new HttpResponse<T>({ | ||||
|                     body: <T> content, | ||||
|                     headers: null, | ||||
|                     headers: undefined, | ||||
|                     status: 200, | ||||
|                     statusText: 'OK', | ||||
|                     url, | ||||
| @ -890,7 +909,7 @@ export class CoreWSProvider { | ||||
|                     break; | ||||
| 
 | ||||
|                 default: | ||||
|                     return Promise.reject(new CoreError('Method not implemented yet.')); | ||||
|                     throw new CoreError('Method not implemented yet.'); | ||||
|             } | ||||
| 
 | ||||
|             if (angularOptions.timeout) { | ||||
| @ -966,6 +985,11 @@ export type CoreWSExternalWarning = { | ||||
|  * Structure of files returned by WS. | ||||
|  */ | ||||
| export type CoreWSExternalFile = { | ||||
|     /** | ||||
|      * Downloadable file url. | ||||
|      */ | ||||
|     fileurl: string; | ||||
| 
 | ||||
|     /** | ||||
|      * File name. | ||||
|      */ | ||||
| @ -981,11 +1005,6 @@ export type CoreWSExternalFile = { | ||||
|      */ | ||||
|     filesize?: number; | ||||
| 
 | ||||
|     /** | ||||
|      * Downloadable file url. | ||||
|      */ | ||||
|     fileurl?: string; | ||||
| 
 | ||||
|     /** | ||||
|      * Time modified. | ||||
|      */ | ||||
| @ -1108,7 +1127,7 @@ export type HttpRequestOptions = { | ||||
|     /** | ||||
|      * Timeout for the request in seconds. If undefined, the default value will be used. If null, no timeout. | ||||
|      */ | ||||
|     timeout?: number | null; | ||||
|     timeout?: number; | ||||
| 
 | ||||
|     /** | ||||
|      * Serializer to use. Defaults to 'urlencoded'. Only for mobile environments. | ||||
| @ -1162,6 +1181,6 @@ type RetryCall = { | ||||
|  * Downloaded file entry. It includes some calculated data. | ||||
|  */ | ||||
| export type CoreWSDownloadedFileEntry = FileEntry & { | ||||
|     extension?: string; // File extension.
 | ||||
|     path?: string; // File path.
 | ||||
|     extension: string; // File extension.
 | ||||
|     path: string; // File path.
 | ||||
| }; | ||||
|  | ||||
| @ -41,7 +41,7 @@ export class CoreArray { | ||||
|             return (arr as any).flat(); // eslint-disable-line @typescript-eslint/no-explicit-any
 | ||||
|         } | ||||
| 
 | ||||
|         return [].concat(...arr); | ||||
|         return (<T[]> []).concat(...arr); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -5,7 +5,7 @@ | ||||
|  */ | ||||
| 
 | ||||
| function initCache () { | ||||
|   const store = [] | ||||
|   const store: any[] = [] | ||||
|   // cache only first element, second is length to jump ahead for the parser
 | ||||
|   const cache = function cache (value) { | ||||
|     store.push(value[0]) | ||||
| @ -316,7 +316,7 @@ function expectArrayItems (str, expectedItems = 0, cache) { | ||||
|   let hasStringKeys = false | ||||
|   let item | ||||
|   let totalOffset = 0 | ||||
|   let items = [] | ||||
|   let items: any[] = [] | ||||
|   cache([items]) | ||||
| 
 | ||||
|   for (let i = 0; i < expectedItems; i++) { | ||||
|  | ||||
| @ -141,7 +141,7 @@ export class CoreUrl { | ||||
|         // If nothing else worked, parse the domain.
 | ||||
|         const urlParts = CoreUrl.parse(url); | ||||
| 
 | ||||
|         return urlParts && urlParts.domain ? urlParts.domain : null; | ||||
|         return urlParts?.domain ? urlParts.domain : null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -196,8 +196,8 @@ export class CoreUrl { | ||||
|         const partsA = CoreUrl.parse(urlA); | ||||
|         const partsB = CoreUrl.parse(urlB); | ||||
| 
 | ||||
|         return partsA.domain == partsB.domain && | ||||
|                 CoreTextUtils.instance.removeEndingSlash(partsA.path) == CoreTextUtils.instance.removeEndingSlash(partsB.path); | ||||
|         return partsA?.domain == partsB?.domain && | ||||
|             CoreTextUtils.instance.removeEndingSlash(partsA?.path) == CoreTextUtils.instance.removeEndingSlash(partsB?.path); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -60,7 +60,7 @@ export class CoreWindow { | ||||
| 
 | ||||
|             await CoreUtils.instance.openFile(url); | ||||
|         } else { | ||||
|             let treated: boolean; | ||||
|             let treated = false; | ||||
|             // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|             options = options || {}; | ||||
| 
 | ||||
| @ -76,7 +76,7 @@ export class CoreWindow { | ||||
|                     // Not logged in, cannot auto-login.
 | ||||
|                     CoreUtils.instance.openInBrowser(url); | ||||
|                 } else { | ||||
|                     await CoreSites.instance.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); | ||||
|                     await CoreSites.instance.getCurrentSite()!.openInBrowserWithAutoLoginIfSameSite(url); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/login_logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/assets/img/login_logo.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 16 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/user-avatar.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/assets/img/user-avatar.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.2 KiB | 
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user