diff --git a/package-lock.json b/package-lock.json index c85e945ba..1d2552ff9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2380,18 +2380,18 @@ } }, "@ionic/angular": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.3.4.tgz", - "integrity": "sha512-eqw03rWvQKM0pcuxPxQvXeCUGhcaQCZ+yqQRp9A6cBDE6u6JJS2tGs6OyZ07hhyDF8IJ5BLpJwjiRjEDnhyTDQ==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.5.2.tgz", + "integrity": "sha512-yXIydPTIMAX4RobidAByaQ/y+yMS6FYgwEs08GTN/GyvQ4XeWVbojwTm62ILLN2qYS/80ok2uupFwlcyKSMztw==", "requires": { - "@ionic/core": "5.3.4", + "@ionic/core": "5.5.2", "tslib": "^1.9.3" }, "dependencies": { "tslib": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", - "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==" + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" } } }, @@ -2424,18 +2424,18 @@ } }, "@ionic/core": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.3.4.tgz", - "integrity": "sha512-4UVzj+Vd7o0VJ06dReG01PvttnLLPSzUVgXSYMBKKR849Pvuh5Q9t5s4GEEQgGoxhv1S6Ai+zphWGFMvviOyfw==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.5.2.tgz", + "integrity": "sha512-rOfPj8D5NRWdOYYulNTdKtMAMURfmutDQ3ciA3L7daCooG3MWt2/0siiL6rcZFMxfG7KDxHctuwVwYoC1mPuhg==", "requires": { "ionicons": "^5.1.2", "tslib": "^1.10.0" }, "dependencies": { "tslib": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", - "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==" + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" } } }, @@ -11042,9 +11042,9 @@ "dev": true }, "ionicons": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-5.1.2.tgz", - "integrity": "sha512-zO7ZgbBbXhpA7cXO2rDzTNdcCqErjg1Sprq/ossTvaiV0MriOjRE7JO3EGvYjDTPzF9YALGpvLXqCgsRT0tprA==" + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-5.2.3.tgz", + "integrity": "sha512-87qtgBkieKVFagwYA9Cf91B3PCahQbEOMwMt8bSvlQSgflZ4eE5qI4MGj2ZlIyadeX0dgo+0CzZsy3ow0CsBAg==" }, "ios-sim": { "version": "8.0.2", diff --git a/package.json b/package.json index 880dcdbd0..4f7510f3e 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@ionic-native/status-bar": "^5.0.0", "@ionic-native/web-intent": "^5.28.0", "@ionic-native/zip": "^5.28.0", - "@ionic/angular": "^5.0.0", + "@ionic/angular": "^5.5.2", "@ngx-translate/core": "^13.0.0", "@ngx-translate/http-loader": "^6.0.0", "@types/cordova": "0.0.34", diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index 66777ebfa..98845ab4a 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -16,11 +16,13 @@ import { NgModule } from '@angular/core'; import { AddonPrivateFilesModule } from './privatefiles/privatefiles.module'; import { AddonFilterModule } from './filter/filter.module'; +import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield.module'; @NgModule({ imports: [ AddonPrivateFilesModule, AddonFilterModule, + AddonUserProfileFieldModule, ], }) export class AddonsModule {} diff --git a/src/addons/privatefiles/privatefiles-lazy.module.ts b/src/addons/privatefiles/privatefiles-lazy.module.ts index 307142373..405d9ff9d 100644 --- a/src/addons/privatefiles/privatefiles-lazy.module.ts +++ b/src/addons/privatefiles/privatefiles-lazy.module.ts @@ -12,23 +12,33 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { Injector, NgModule } from '@angular/core'; +import { RouterModule, ROUTES, Routes } from '@angular/router'; -const routes: Routes = [ - { - path: '', - redirectTo: 'root', // Fake "hash". - pathMatch: 'full', - }, - { - path: ':hash', - loadChildren: () => import('./pages/index/index.module').then(m => m.AddonPrivateFilesIndexPageModule), - }, -]; +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; + +function buildRoutes(injector: Injector): Routes { + return [ + { + path: ':hash', + loadChildren: () => import('./pages/index/index.module').then(m => m.AddonPrivateFilesIndexPageModule), + }, + ...buildTabMainRoutes(injector, { + redirectTo: 'root', // Fake "hash". + pathMatch: 'full', + }), + ]; +} @NgModule({ - imports: [RouterModule.forChild(routes)], exports: [RouterModule], + providers: [ + { + provide: ROUTES, + multi: true, + deps: [Injector], + useFactory: buildRoutes, + }, + ], }) export class AddonPrivateFilesLazyModule {} diff --git a/src/addons/userprofilefield/checkbox/checkbox.module.ts b/src/addons/userprofilefield/checkbox/checkbox.module.ts new file mode 100644 index 000000000..4b232c5e2 --- /dev/null +++ b/src/addons/userprofilefield/checkbox/checkbox.module.ts @@ -0,0 +1,54 @@ +// (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 { APP_INITIALIZER, 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 { AddonUserProfileFieldCheckboxHandler } from './services/handlers/checkbox'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldCheckboxComponent } from './component/checkbox'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldCheckboxComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldCheckboxHandler.instance), + }, + ], + exports: [ + AddonUserProfileFieldCheckboxComponent, + ], + entryComponents: [ + AddonUserProfileFieldCheckboxComponent, + ], +}) +export class AddonUserProfileFieldCheckboxModule {} diff --git a/src/addons/userprofilefield/checkbox/component/addon-user-profile-field-checkbox.html b/src/addons/userprofilefield/checkbox/component/addon-user-profile-field-checkbox.html new file mode 100644 index 000000000..eaa9ec043 --- /dev/null +++ b/src/addons/userprofilefield/checkbox/component/addon-user-profile-field-checkbox.html @@ -0,0 +1,22 @@ + + + +

{{ field.name }}

+

+ {{ 'core.yes' | translate }} +

+

+ {{ 'core.no' | translate }} +

+
+
+ + + + + {{ field.name }} + + + + + \ No newline at end of file diff --git a/src/addons/userprofilefield/checkbox/component/checkbox.ts b/src/addons/userprofilefield/checkbox/component/checkbox.ts new file mode 100644 index 000000000..672307930 --- /dev/null +++ b/src/addons/userprofilefield/checkbox/component/checkbox.ts @@ -0,0 +1,45 @@ +// (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 } from '@angular/core'; +import { Validators, FormControl } from '@angular/forms'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Directive to render a checkbox user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-checkbox', + templateUrl: 'addon-user-profile-field-checkbox.html', +}) +export class AddonUserProfileFieldCheckboxComponent extends CoreUserProfileFieldBaseComponent { + + /** + * Create the Form control. + * + * @return Form control. + */ + protected createFormControl(field: AuthEmailSignupProfileField): FormControl { + const formData = { + value: CoreUtils.instance.isTrueOrOne(field.defaultdata), + disabled: this.disabled, + }; + + return new FormControl(formData, this.required && !field.locked ? Validators.requiredTrue : null); + } + +} diff --git a/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts b/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts new file mode 100644 index 000000000..f085e2f3b --- /dev/null +++ b/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts @@ -0,0 +1,79 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonUserProfileFieldCheckboxComponent } from '../../component/checkbox'; + +/** + * Checkbox user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldCheckboxHandlerService implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldCheckbox'; + type = 'checkbox'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + if (typeof formValues[name] != 'undefined') { + return { + type: 'checkbox', + name: name, + value: formValues[name] ? 1 : 0, + }; + } + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldCheckboxComponent; + } + +} + +export class AddonUserProfileFieldCheckboxHandler extends makeSingleton(AddonUserProfileFieldCheckboxHandlerService) {} diff --git a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html new file mode 100644 index 000000000..7d75067a0 --- /dev/null +++ b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html @@ -0,0 +1,18 @@ + + + +

{{ field.name }}

+

{{ valueNumber * 1000 | coreFormatDate }}

+
+
+ + + + + {{ field.name }} + + + + + \ No newline at end of file diff --git a/src/addons/userprofilefield/datetime/component/datetime.ts b/src/addons/userprofilefield/datetime/component/datetime.ts new file mode 100644 index 000000000..3deb698d2 --- /dev/null +++ b/src/addons/userprofilefield/datetime/component/datetime.ts @@ -0,0 +1,95 @@ +// (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 { FormControl, Validators } from '@angular/forms'; +import { Component } from '@angular/core'; + +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { Translate } from '@singletons'; +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; + +/** + * Directive to render a datetime user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-datetime', + templateUrl: 'addon-user-profile-field-datetime.html', +}) +export class AddonUserProfileFieldDatetimeComponent extends CoreUserProfileFieldBaseComponent { + + format?: string; + min?: number; + max?: number; + valueNumber = 0; + + /** + * Init the data when the field is meant to be displayed without editing. + * + * @param field Field to render. + */ + protected initForNonEdit(field: CoreUserProfileField): void { + this.valueNumber = Number(field.value); + } + + /** + * Init the data when the field is meant to be displayed for editing. + * + * @param field Field to render. + */ + protected initForEdit(field: AuthEmailSignupProfileField): void { + super.initForEdit(field); + + // Check if it's only date or it has time too. + const hasTime = CoreUtils.instance.isTrueOrOne(field.param3); + + // Calculate format to use. + this.format = CoreTimeUtils.instance.fixFormatForDatetime(CoreTimeUtils.instance.convertPHPToMoment( + Translate.instance.instant('core.' + (hasTime ? 'strftimedatetime' : 'strftimedate')), + )); + + // Check min value. + if (field.param1) { + const year = parseInt(field.param1, 10); + if (year) { + this.min = year; + } + } + + // Check max value. + if (field.param2) { + const year = parseInt(field.param2, 10); + if (year) { + this.max = year; + } + } + } + + /** + * Create the Form control. + * + * @return Form control. + */ + protected createFormControl(field: AuthEmailSignupProfileField): FormControl { + const formData = { + value: field.defaultdata != '0' ? field.defaultdata : undefined, + disabled: this.disabled, + }; + + return new FormControl(formData, this.required && !field.locked ? Validators.required : null); + } + +} diff --git a/src/addons/userprofilefield/datetime/datetime.module.ts b/src/addons/userprofilefield/datetime/datetime.module.ts new file mode 100644 index 000000000..4d9625d8a --- /dev/null +++ b/src/addons/userprofilefield/datetime/datetime.module.ts @@ -0,0 +1,56 @@ +// (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 { APP_INITIALIZER, 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 { AddonUserProfileFieldDatetimeHandler } from './services/handlers/datetime'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldDatetimeComponent } from './component/datetime'; +import { CoreComponentsModule } from '@components/components.module'; +import { CorePipesModule } from '@pipes/pipes.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldDatetimeComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + CorePipesModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldDatetimeHandler.instance), + }, + ], + exports: [ + AddonUserProfileFieldDatetimeComponent, + ], + entryComponents: [ + AddonUserProfileFieldDatetimeComponent, + ], +}) +export class AddonUserProfileFieldDatetimeModule {} diff --git a/src/addons/userprofilefield/datetime/services/handlers/datetime.ts b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts new file mode 100644 index 000000000..5a1ab635f --- /dev/null +++ b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts @@ -0,0 +1,81 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { CoreTimeUtils } from '@services/utils/time'; +import { makeSingleton } from '@singletons'; +import { AddonUserProfileFieldDatetimeComponent } from '../../component/datetime'; + +/** + * Datetime user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldDatetimeHandlerService implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldDatetime'; + type = 'datetime'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + if (formValues[name]) { + return { + type: 'datetime', + name: 'profile_field_' + field.shortname, + value: CoreTimeUtils.instance.convertToTimestamp( formValues[name]), + }; + } + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldDatetimeComponent; + } + +} + +export class AddonUserProfileFieldDatetimeHandler extends makeSingleton(AddonUserProfileFieldDatetimeHandlerService) {} diff --git a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html new file mode 100644 index 000000000..ce2e84f68 --- /dev/null +++ b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html @@ -0,0 +1,21 @@ + + + +

{{ field.name }}

+

+

+
+
+ + + + + {{ field.name }} + + + {{ 'core.choosedots' | translate }} + {{option}} + + + diff --git a/src/addons/userprofilefield/menu/component/menu.ts b/src/addons/userprofilefield/menu/component/menu.ts new file mode 100644 index 000000000..b497b69de --- /dev/null +++ b/src/addons/userprofilefield/menu/component/menu.ts @@ -0,0 +1,47 @@ +// (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 } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; + +/** + * Directive to render a menu user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-menu', + templateUrl: 'addon-user-profile-field-menu.html', +}) +export class AddonUserProfileFieldMenuComponent extends CoreUserProfileFieldBaseComponent { + + options?: string[]; + + /** + * Init the data when the field is meant to be displayed for editing. + * + * @param field Field to render. + */ + protected initForEdit(field: AuthEmailSignupProfileField): void { + super.initForEdit(field); + + // Parse options. + if (field.param1) { + this.options = field.param1.split(/\r\n|\r|\n/g); + } else { + this.options = []; + } + } + +} diff --git a/src/addons/userprofilefield/menu/menu.module.ts b/src/addons/userprofilefield/menu/menu.module.ts new file mode 100644 index 000000000..792d82624 --- /dev/null +++ b/src/addons/userprofilefield/menu/menu.module.ts @@ -0,0 +1,56 @@ +// (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 { APP_INITIALIZER, 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 { AddonUserProfileFieldMenuHandler } from './services/handlers/menu'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldMenuComponent } from './component/menu'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldMenuComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + CoreDirectivesModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldMenuHandler.instance), + }, + ], + exports: [ + AddonUserProfileFieldMenuComponent, + ], + entryComponents: [ + AddonUserProfileFieldMenuComponent, + ], +}) +export class AddonUserProfileFieldMenuModule {} diff --git a/src/addons/userprofilefield/menu/services/handlers/menu.ts b/src/addons/userprofilefield/menu/services/handlers/menu.ts new file mode 100644 index 000000000..7a9396aed --- /dev/null +++ b/src/addons/userprofilefield/menu/services/handlers/menu.ts @@ -0,0 +1,79 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonUserProfileFieldMenuComponent } from '../../component/menu'; + +/** + * Menu user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldMenuHandlerService implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldMenu'; + type = 'menu'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + if (formValues[name]) { + return { + type: 'menu', + name: name, + value: formValues[name], + }; + } + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldMenuComponent; + } + +} + +export class AddonUserProfileFieldMenuHandler extends makeSingleton(AddonUserProfileFieldMenuHandlerService) {} diff --git a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html new file mode 100644 index 000000000..51d52320d --- /dev/null +++ b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html @@ -0,0 +1,18 @@ + + + +

{{ field.name }}

+

+

+
+
+ + + + + {{ field.name }} + + + + diff --git a/src/addons/userprofilefield/text/component/text.ts b/src/addons/userprofilefield/text/component/text.ts new file mode 100644 index 000000000..3b7f42030 --- /dev/null +++ b/src/addons/userprofilefield/text/component/text.ts @@ -0,0 +1,50 @@ +// (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 } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Directive to render a text user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-text', + templateUrl: 'addon-user-profile-field-text.html', +}) +export class AddonUserProfileFieldTextComponent extends CoreUserProfileFieldBaseComponent { + + inputType?: string; + maxLength?: number; + + /** + * Init the data when the field is meant to be displayed for editing. + * + * @param field Field to render. + */ + protected initForEdit(field: AuthEmailSignupProfileField): void { + super.initForEdit(field); + + // Check max length. + if (field.param2) { + this.maxLength = parseInt(field.param2, 10) || Number.MAX_VALUE; + } + + // Check if it's a password or text. + this.inputType = CoreUtils.instance.isTrueOrOne(field.param3) ? 'password' : 'text'; + } + +} diff --git a/src/addons/userprofilefield/text/services/handlers/text.ts b/src/addons/userprofilefield/text/services/handlers/text.ts new file mode 100644 index 000000000..630872d5e --- /dev/null +++ b/src/addons/userprofilefield/text/services/handlers/text.ts @@ -0,0 +1,78 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldTextComponent } from '../../component/text'; +import { CoreTextUtils } from '@services/utils/text'; +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { makeSingleton } from '@singletons'; + +/** + * Text user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldTextHandlerService implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldText'; + type = 'text'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + return { + type: 'text', + name: name, + value: CoreTextUtils.instance.cleanTags( formValues[name]), + }; + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldTextComponent; + } + +} + +export class AddonUserProfileFieldTextHandler extends makeSingleton(AddonUserProfileFieldTextHandlerService) {} diff --git a/src/addons/userprofilefield/text/text.module.ts b/src/addons/userprofilefield/text/text.module.ts new file mode 100644 index 000000000..edc4f1299 --- /dev/null +++ b/src/addons/userprofilefield/text/text.module.ts @@ -0,0 +1,56 @@ +// (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 { APP_INITIALIZER, 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 { AddonUserProfileFieldTextHandler } from './services/handlers/text'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldTextComponent } from './component/text'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldTextComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + CoreDirectivesModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldTextHandler.instance), + }, + ], + exports: [ + AddonUserProfileFieldTextComponent, + ], + entryComponents: [ + AddonUserProfileFieldTextComponent, + ], +}) +export class AddonUserProfileFieldTextModule {} diff --git a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html new file mode 100644 index 000000000..6903fb29f --- /dev/null +++ b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html @@ -0,0 +1,20 @@ + + + +

{{ field.name }}

+

+

+
+
+ + + + + {{ field.name }} + + + + + \ No newline at end of file diff --git a/src/addons/userprofilefield/textarea/component/textarea.ts b/src/addons/userprofilefield/textarea/component/textarea.ts new file mode 100644 index 000000000..affe2f616 --- /dev/null +++ b/src/addons/userprofilefield/textarea/component/textarea.ts @@ -0,0 +1,26 @@ +// (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 } from '@angular/core'; + +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; + +/** + * Directive to render a textarea user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-textarea', + templateUrl: 'addon-user-profile-field-textarea.html', +}) +export class AddonUserProfileFieldTextareaComponent extends CoreUserProfileFieldBaseComponent {} diff --git a/src/addons/userprofilefield/textarea/services/handlers/textarea.ts b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts new file mode 100644 index 000000000..e1a233652 --- /dev/null +++ b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts @@ -0,0 +1,88 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldTextareaComponent } from '../../component/textarea'; +import { CoreTextUtils } from '@services/utils/text'; +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { makeSingleton } from '@singletons'; + +/** + * Textarea user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldTextareaHandlerService implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldTextarea'; + type = 'textarea'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + if (formValues[name]) { + let text = formValues[name] || ''; + // Add some HTML to the message in case the user edited with textarea. + text = CoreTextUtils.instance.formatHtmlLines(text); + + return { + type: 'textarea', + name: name, + value: JSON.stringify({ + text: text, + format: 1, // Always send this format. + }), + }; + } + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldTextareaComponent; + } + +} + +export class AddonUserProfileFieldTextareaHandler extends makeSingleton(AddonUserProfileFieldTextareaHandlerService) {} diff --git a/src/addons/userprofilefield/textarea/textarea.module.ts b/src/addons/userprofilefield/textarea/textarea.module.ts new file mode 100644 index 000000000..e07ef0e71 --- /dev/null +++ b/src/addons/userprofilefield/textarea/textarea.module.ts @@ -0,0 +1,58 @@ +// (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 { APP_INITIALIZER, 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 { AddonUserProfileFieldTextareaHandler } from './services/handlers/textarea'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldTextareaComponent } from './component/textarea'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldTextareaComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + CoreDirectivesModule, + CoreEditorComponentsModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldTextareaHandler.instance), + }, + ], + exports: [ + AddonUserProfileFieldTextareaComponent, + ], + entryComponents: [ + AddonUserProfileFieldTextareaComponent, + ], +}) +export class AddonUserProfileFieldTextareaModule {} diff --git a/src/addons/userprofilefield/userprofilefield.module.ts b/src/addons/userprofilefield/userprofilefield.module.ts new file mode 100644 index 000000000..50910d3a2 --- /dev/null +++ b/src/addons/userprofilefield/userprofilefield.module.ts @@ -0,0 +1,33 @@ +// (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 { NgModule } from '@angular/core'; + +import { AddonUserProfileFieldCheckboxModule } from './checkbox/checkbox.module'; +import { AddonUserProfileFieldDatetimeModule } from './datetime/datetime.module'; +import { AddonUserProfileFieldMenuModule } from './menu/menu.module'; +import { AddonUserProfileFieldTextModule } from './text/text.module'; +import { AddonUserProfileFieldTextareaModule } from './textarea/textarea.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonUserProfileFieldCheckboxModule, + AddonUserProfileFieldDatetimeModule, + AddonUserProfileFieldMenuModule, + AddonUserProfileFieldTextModule, + AddonUserProfileFieldTextareaModule, + ], + exports: [], +}) +export class AddonUserProfileFieldModule { } diff --git a/src/core/classes/base-sync.ts b/src/core/classes/base-sync.ts new file mode 100644 index 000000000..4f7c1b63f --- /dev/null +++ b/src/core/classes/base-sync.ts @@ -0,0 +1,307 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { Translate } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreError } from '@classes/errors/error'; + +/** + * Blocked sync error. + */ +export class CoreSyncBlockedError extends CoreError {} + +/** + * Base class to create sync providers. It provides some common functions. + */ +export class CoreSyncBaseProvider { + + /** + * Logger instance. + */ + protected logger: CoreLogger; + + /** + * Component of the sync provider. + */ + component = 'core'; + + /** + * Sync provider's interval. + */ + syncInterval = 300000; + + // Store sync promises. + protected syncPromises: { [siteId: string]: { [uniqueId: string]: Promise } } = {}; + + constructor(component: string) { + this.logger = CoreLogger.getInstance(component); + this.component = component; + } + + /** + * Add an offline data deleted warning to a list of warnings. + * + * @param warnings List of warnings. + * @param component Component. + * @param name Instance name. + * @param error Specific error message. + */ + protected addOfflineDataDeletedWarning(warnings: string[], component: string, name: string, error: string): void { + const warning = Translate.instance.instant('core.warningofflinedatadeleted', { + component: component, + name: name, + error: error, + }); + + if (warnings.indexOf(warning) == -1) { + warnings.push(warning); + } + } + + /** + * Add an ongoing sync to the syncPromises list. On finish the promise will be removed. + * + * @param id Unique sync identifier per component. + * @param promise The promise of the sync to add. + * @param siteId Site ID. If not defined, current site. + * @return The sync promise. + */ + async addOngoingSync(id: string | number, promise: Promise, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + throw new CoreError('CoreSyncBaseProvider: Site ID not supplied'); + } + + const uniqueId = this.getUniqueSyncId(id); + if (!this.syncPromises[siteId]) { + this.syncPromises[siteId] = {}; + } + + this.syncPromises[siteId][uniqueId] = promise; + + // Promise will be deleted when finish. + try { + return await promise; + } finally { + delete this.syncPromises[siteId!][uniqueId]; + } + } + + /** + * If there's an ongoing sync for a certain identifier return it. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise of the current sync or undefined if there isn't any. + */ + getOngoingSync(id: string | number, siteId?: string): Promise | undefined { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!this.isSyncing(id, siteId)) { + return; + } + + // There's already a sync ongoing for this id, return the promise. + const uniqueId = this.getUniqueSyncId(id); + + return this.syncPromises[siteId][uniqueId]; + } + + /** + * Get the synchronization time in a human readable format. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the readable time. + */ + async getReadableSyncTime(id: string | number, siteId?: string): Promise { + const time = await this.getSyncTime(id, siteId); + + return this.getReadableTimeFromTimestamp(time); + } + + /** + * Given a timestamp return it in a human readable format. + * + * @param timestamp Timestamp + * @return Human readable time. + */ + getReadableTimeFromTimestamp(timestamp: number): string { + if (!timestamp) { + return Translate.instance.instant('core.never'); + } else { + return CoreTimeUtils.instance.userDate(timestamp); + } + } + + /** + * Get the synchronization time. Returns 0 if no time stored. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the time. + */ + async getSyncTime(id: string | number, siteId?: string): Promise { + try { + const entry = await CoreSync.instance.getSyncRecord(this.component, id, siteId); + + return entry.time; + } catch { + return 0; + } + } + + /** + * Get the synchronization warnings of an instance. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the warnings. + */ + async getSyncWarnings(id: string | number, siteId?: string): Promise { + try { + const entry = await CoreSync.instance.getSyncRecord(this.component, id, siteId); + + return CoreTextUtils.instance.parseJSON(entry.warnings, []); + } catch { + return []; + } + } + + /** + * Create a unique identifier from component and id. + * + * @param id Unique sync identifier per component. + * @return Unique identifier from component and id. + */ + protected getUniqueSyncId(id: string | number): string { + return this.component + '#' + id; + } + + /** + * Check if a there's an ongoing syncronization for the given id. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Whether it's synchronizing. + */ + isSyncing(id: string | number, siteId?: string): boolean { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const uniqueId = this.getUniqueSyncId(id); + + return !!(this.syncPromises[siteId] && this.syncPromises[siteId][uniqueId]); + } + + /** + * Check if a sync is needed: if a certain time has passed since the last time. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether sync is needed. + */ + async isSyncNeeded(id: string | number, siteId?: string): Promise { + const time = await this.getSyncTime(id, siteId); + + return Date.now() - this.syncInterval >= time; + } + + /** + * Set the synchronization time. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @param time Time to set. If not defined, current time. + * @return Promise resolved when the time is set. + */ + async setSyncTime(id: string, siteId?: string, time?: number): Promise { + time = typeof time != 'undefined' ? time : Date.now(); + + await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { time: time }, siteId); + } + + /** + * Set the synchronization warnings. + * + * @param id Unique sync identifier per component. + * @param warnings Warnings to set. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async setSyncWarnings(id: string, warnings: string[], siteId?: string): Promise { + const warningsText = JSON.stringify(warnings || []); + + await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { warnings: warningsText }, siteId); + } + + /** + * Execute a sync function on selected sites. + * + * @param syncFunctionLog Log message to explain the sync function purpose. + * @param syncFunction Sync function to execute. + * @param siteId Site ID to sync. If not defined, sync all sites. + * @return Resolved with siteIds selected. Rejected if offline. + */ + async syncOnSites(syncFunctionLog: string, syncFunction: (siteId: string) => void, siteId?: string): Promise { + if (!CoreApp.instance.isOnline()) { + const message = `Cannot sync '${syncFunctionLog}' because device is offline.`; + this.logger.debug(message); + + throw new CoreError(message); + } + + let siteIds: string[] = []; + + if (!siteId) { + // No site ID defined, sync all sites. + this.logger.debug(`Try to sync '${syncFunctionLog}' in all sites.`); + siteIds = await CoreSites.instance.getLoggedInSitesIds(); + } else { + this.logger.debug(`Try to sync '${syncFunctionLog}' in site '${siteId}'.`); + siteIds = [siteId]; + } + + // Execute function for every site. + await Promise.all(siteIds.map((siteId) => syncFunction(siteId))); + } + + /** + * If there's an ongoing sync for a certain identifier, wait for it to end. + * If there's no sync ongoing the promise will be resolved right away. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when there's no sync going on for the identifier. + */ + async waitForSync(id: string | number, siteId?: string): Promise { + const promise = this.getOngoingSync(id, siteId); + + if (!promise) { + return; + } + + try { + return await promise; + } catch { + return; + } + } + +} diff --git a/src/core/classes/delegate.ts b/src/core/classes/delegate.ts index c9d0183b4..41aac25c5 100644 --- a/src/core/classes/delegate.ts +++ b/src/core/classes/delegate.ts @@ -86,7 +86,7 @@ export class CoreDelegate { * @param delegateName Delegate name used for logging purposes. * @param listenSiteEvents Whether to update the handler when a site event occurs (login, site updated, ...). */ - constructor(delegateName: string, listenSiteEvents?: boolean) { + constructor(delegateName: string, listenSiteEvents: boolean = true) { this.logger = CoreLogger.getInstance(delegateName); this.handlersInitPromise = new Promise((resolve): void => { diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index bd6df82ff..cc24d2297 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -17,7 +17,7 @@ import { Md5 } from 'ts-md5/dist/md5'; import { CoreApp } from '@services/app'; import { CoreDB } from '@services/db'; -import { CoreEvents } from '@singletons/events'; +import { CoreEvents, CoreEventUserDeletedData } from '@singletons/events'; import { CoreFile } from '@services/file'; import { CoreWS, @@ -588,7 +588,7 @@ export class CoreSite { error.message = Translate.instance.instant('core.lostconnection'); } else if (error.errorcode === 'userdeleted') { // User deleted, trigger event. - CoreEvents.trigger(CoreEvents.USER_DELETED, { params: data }, this.id); + CoreEvents.trigger(CoreEvents.USER_DELETED, { params: data }, this.id); error.message = Translate.instance.instant('core.userdeleted'); throw new CoreWSError(error); diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index cfa381f7f..416cd3999 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -35,6 +35,8 @@ import { CoreProgressBarComponent } from './progress-bar/progress-bar'; import { CoreContextMenuComponent } from './context-menu/context-menu'; import { CoreContextMenuItemComponent } from './context-menu/context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover'; +import { CoreUserAvatarComponent } from './user-avatar/user-avatar'; +import { CoreDynamicComponent } from './dynamic-component/dynamic-component'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; @@ -61,6 +63,8 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; CoreContextMenuItemComponent, CoreContextMenuPopoverComponent, CoreNavBarButtonsComponent, + CoreUserAvatarComponent, + CoreDynamicComponent, ], imports: [ CommonModule, @@ -89,6 +93,8 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; CoreContextMenuItemComponent, CoreContextMenuPopoverComponent, CoreNavBarButtonsComponent, + CoreUserAvatarComponent, + CoreDynamicComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/dynamic-component/core-dynamic-component.html b/src/core/components/dynamic-component/core-dynamic-component.html new file mode 100644 index 000000000..99c89fec9 --- /dev/null +++ b/src/core/components/dynamic-component/core-dynamic-component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/core/components/dynamic-component/dynamic-component.ts b/src/core/components/dynamic-component/dynamic-component.ts new file mode 100644 index 000000000..943ec0a4c --- /dev/null +++ b/src/core/components/dynamic-component/dynamic-component.ts @@ -0,0 +1,200 @@ +// (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, + ViewChild, + OnChanges, + DoCheck, + ViewContainerRef, + ComponentFactoryResolver, + ComponentRef, + KeyValueDiffers, + SimpleChange, + ChangeDetectorRef, + Optional, + ElementRef, + KeyValueDiffer, + Type, +} from '@angular/core'; +import { NavController } from '@ionic/angular'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Component to create another component dynamically. + * + * You need to pass the class of the component to this component (the class, not the name), along with the input data. + * + * So you should do something like: + * + * import { MyComponent } from './component'; + * + * ... + * + * this.component = MyComponent; + * + * And in the template: + * + * + *

Cannot render the data.

+ *
+ * + * Please notice that the component that you pass needs to be declared in entryComponents of the module to be created dynamically. + * + * Alternatively, you can also supply a ComponentRef instead of the class of the component. In this case, the component won't + * be instantiated because it already is, it will be attached to the view and the right data will be passed to it. + * Passing ComponentRef is meant for site plugins, so we'll inject a NavController instance to the component. + * + * The contents of this component will be displayed if no component is supplied or it cannot be created. In the example above, + * if no component is supplied then the template will show the message "Cannot render the data.". + */ +/* eslint-disable @angular-eslint/no-conflicting-lifecycle */ +@Component({ + selector: 'core-dynamic-component', + templateUrl: 'core-dynamic-component.html', +}) +export class CoreDynamicComponent implements OnChanges, DoCheck { + + @Input() component?: Type; + @Input() data?: Record; + + // Get the container where to put the dynamic component. + @ViewChild('dynamicComponent', { read: ViewContainerRef }) set dynamicComponent(el: ViewContainerRef) { + this.container = el; + + // Use a timeout to avoid ExpressionChangedAfterItHasBeenCheckedError. + setTimeout(() => this.createComponent()); + } + + instance?: any; // eslint-disable-line @typescript-eslint/no-explicit-any + container?: ViewContainerRef; + + protected logger: CoreLogger; + protected differ: KeyValueDiffer; // To detect changes in the data input. + protected lastComponent?: Type; + + constructor( + protected factoryResolver: ComponentFactoryResolver, + differs: KeyValueDiffers, + @Optional() protected navCtrl: NavController, + protected cdr: ChangeDetectorRef, + protected element: ElementRef, + ) { + + this.logger = CoreLogger.getInstance('CoreDynamicComponent'); + this.differ = differs.find([]).create(); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.component && !this.component) { + // Component not set, destroy the instance if any. + this.lastComponent = undefined; + this.instance = undefined; + this.container?.clear(); + } else if (changes.component && (!this.instance || this.component != this.lastComponent)) { + this.createComponent(); + } + } + + /** + * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). + */ + ngDoCheck(): void { + if (this.instance) { + // Check if there's any change in the data object. + const changes = this.differ.diff(this.data || {}); + if (changes) { + this.setInputData(); + if (this.instance.ngOnChanges) { + this.instance.ngOnChanges(CoreDomUtils.instance.createChangesFromKeyValueDiff(changes)); + } + } + } + } + + /** + * Call a certain function on the component. + * + * @param name Name of the function to call. + * @param params List of params to send to the function. + * @return Result of the call. Undefined if no component instance or the function doesn't exist. + */ + callComponentFunction(name: string, params?: unknown[]): T | undefined { + if (this.instance && typeof this.instance[name] == 'function') { + return this.instance[name].apply(this.instance, params); + } + } + + /** + * Create a component, add it to a container and set the input data. + * + * @return Whether the component was successfully created. + */ + protected createComponent(): boolean { + this.lastComponent = this.component; + + if (!this.component || !this.container) { + // No component to instantiate or container doesn't exist right now. + return false; + } + + if (this.instance) { + // Component already instantiated. + return true; + } + + if (this.component instanceof ComponentRef) { + // A ComponentRef was supplied instead of the component class. Add it to the view. + this.container.insert(this.component.hostView); + this.instance = this.component.instance; + + // This feature is usually meant for site plugins. Inject some properties. + this.instance['ChangeDetectorRef'] = this.cdr; + this.instance['NavController'] = this.navCtrl; + this.instance['componentContainer'] = this.element.nativeElement; + } else { + try { + // Create the component and add it to the container. + const factory = this.factoryResolver.resolveComponentFactory(this.component); + const componentRef = this.container.createComponent(factory); + + this.instance = componentRef.instance; + } catch (ex) { + this.logger.error('Error creating component', ex); + + return false; + } + } + + this.setInputData(); + + return true; + } + + /** + * Set the input data for the component. + */ + protected setInputData(): void { + for (const name in this.data) { + this.instance[name] = this.data[name]; + } + } + +} diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index fd8cbfd53..a1cb8137a 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -6,7 +6,7 @@ - { this.numTabsShown = this.tabs.reduce((prev: number, current: CoreTab) => current.enabled ? prev + 1 : prev, 0); - this.slideOpts.slidesPerView = Math.min(this.maxSlides, this.numTabsShown); - this.slidesSwiper.params.slidesPerView = this.slideOpts.slidesPerView; + this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) }; this.calculateTabBarHeight(); await this.slides!.update(); - if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slideOpts.slidesPerView) { + if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { this.hasSliddenToInitial = true; this.shouldSlideToInitial = true; @@ -637,6 +636,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe window.removeEventListener('resize', this.resizeFunction); } this.stackEventsSubscription?.unsubscribe(); + this.languageChangedSubscription.unsubscribe(); } } diff --git a/src/core/components/user-avatar/core-user-avatar.html b/src/core/components/user-avatar/core-user-avatar.html new file mode 100644 index 000000000..318573f43 --- /dev/null +++ b/src/core/components/user-avatar/core-user-avatar.html @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/core/components/user-avatar/user-avatar.scss b/src/core/components/user-avatar/user-avatar.scss new file mode 100644 index 000000000..94b738f0e --- /dev/null +++ b/src/core/components/user-avatar/user-avatar.scss @@ -0,0 +1,43 @@ +:host { + position: relative; + cursor: pointer; + img { + border-radius: 50%; + width: var(--core-avatar-size); + height: var(--core-avatar-size); + } + + .contact-status { + position: absolute; + right: 0; + bottom: 0; + width: 14px; + height: 14px; + border-radius: 50%; + &.online { + border: 1px solid white; + background-color: var(--core-online-color); + } + } + + .core-avatar-extra-icon { + margin: 0 !important; + border-radius: 0 !important; + background: none; + position: absolute; + right: -4px; + bottom: -4px; + width: 24px; + height: 24px; + } +} + +:host-context(.toolbar) .contact-status { + width: 10px; + height: 10px; +} + +:host-context([dir="rtl"]) .contact-status { + left: 0; + right: unset; +} diff --git a/src/core/components/user-avatar/user-avatar.ts b/src/core/components/user-avatar/user-avatar.ts new file mode 100644 index 000000000..dbbd25b31 --- /dev/null +++ b/src/core/components/user-avatar/user-avatar.ts @@ -0,0 +1,179 @@ +// (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, OnDestroy, SimpleChange } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NavController } from '@ionic/angular'; + +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreObject } from '@singletons/object'; +import { CoreUserProvider, CoreUserBasicData, CoreUserProfilePictureUpdatedData } from '@features/user/services/user'; + +/** + * Component to display a "user avatar". + * + * Example: + */ +@Component({ + selector: 'core-user-avatar', + templateUrl: 'core-user-avatar.html', + styleUrls: ['user-avatar.scss'], +}) +export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy { + + @Input() user?: CoreUserWithAvatar; + // The following params will override the ones in user object. + @Input() profileUrl?: string; + @Input() protected linkProfile = true; // Avoid linking to the profile if wanted. + @Input() fullname?: string; + @Input() protected userId?: number; // If provided or found it will be used to link the image to the profile. + @Input() protected courseId?: number; + @Input() checkOnline = false; // If want to check and show online status. + @Input() extraIcon?: string; // Extra icon to show near the avatar. + + avatarUrl?: string; + + // Variable to check if we consider this user online or not. + // @TODO: Use setting when available (see MDL-63972) so we can use site setting. + protected timetoshowusers = 300000; // Miliseconds default. + protected currentUserId: number; + protected pictureObserver: CoreEventObserver; + + constructor( + protected navCtrl: NavController, + protected route: ActivatedRoute, + ) { + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + + this.pictureObserver = CoreEvents.on( + CoreUserProvider.PROFILE_PICTURE_UPDATED, + (data) => { + if (data.userId == this.userId) { + this.avatarUrl = data.picture; + } + }, + CoreSites.instance.getCurrentSiteId(), + ); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.setFields(); + } + + /** + * Listen to changes. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + // If something change, update the fields. + if (changes) { + this.setFields(); + } + } + + /** + * Set fields from user. + */ + protected setFields(): void { + const profileUrl = this.profileUrl || (this.user && (this.user.profileimageurl || this.user.userprofileimageurl || + this.user.userpictureurl || this.user.profileimageurlsmall || (this.user.urls && this.user.urls.profileimage))); + + if (typeof profileUrl == 'string') { + this.avatarUrl = profileUrl; + } + + this.fullname = this.fullname || (this.user && (this.user.fullname || this.user.userfullname)); + + this.userId = this.userId || (this.user && (this.user.userid || this.user.id)); + this.courseId = this.courseId || (this.user && this.user.courseid); + } + + /** + * Helper function for checking the time meets the 'online' condition. + * + * @return boolean + */ + isOnline(): boolean { + if (!this.user) { + return false; + } + + if (CoreUtils.instance.isFalseOrZero(this.user.isonline)) { + return false; + } + + if (this.user.lastaccess) { + // If the time has passed, don't show the online status. + const time = new Date().getTime() - this.timetoshowusers; + + return this.user.lastaccess * 1000 >= time; + } else { + // You have to have Internet access first. + return !!this.user.isonline && CoreApp.instance.isOnline(); + } + } + + /** + * Go to user profile. + * + * @param event Click event. + */ + gotoProfile(event: Event): void { + if (!this.linkProfile || !this.userId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + // @todo Decide which navCtrl to use. If this component is inside a split view, use the split view's master nav. + this.navCtrl.navigateForward(['user'], { + relativeTo: this.route, + queryParams: CoreObject.removeUndefined({ + userId: this.userId, + courseId: this.courseId, + }), + }); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.pictureObserver.off(); + } + +} + +/** + * Type with all possible formats of user. + */ +type CoreUserWithAvatar = CoreUserBasicData & { + userpictureurl?: string; + userprofileimageurl?: string; + profileimageurlsmall?: string; + urls?: { + profileimage?: string; + }; + userfullname?: string; + userid?: number; + isonline?: boolean; + courseid?: number; + lastaccess?: number; +}; diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts index 3be437b3c..06e4c6fa5 100644 --- a/src/core/directives/directives.module.ts +++ b/src/core/directives/directives.module.ts @@ -22,6 +22,7 @@ import { CoreLinkDirective } from './link'; import { CoreLongPressDirective } from './long-press'; import { CoreSupressEventsDirective } from './supress-events'; import { CoreFaIconDirective } from './fa-icon'; +import { CoreUserLinkDirective } from './user-link'; @NgModule({ declarations: [ @@ -33,6 +34,7 @@ import { CoreFaIconDirective } from './fa-icon'; CoreSupressEventsDirective, CoreFabDirective, CoreFaIconDirective, + CoreUserLinkDirective, ], imports: [], exports: [ @@ -44,6 +46,7 @@ import { CoreFaIconDirective } from './fa-icon'; CoreSupressEventsDirective, CoreFabDirective, CoreFaIconDirective, + CoreUserLinkDirective, ], }) export class CoreDirectivesModule {} diff --git a/src/core/directives/user-link.ts b/src/core/directives/user-link.ts new file mode 100644 index 000000000..3099ae77e --- /dev/null +++ b/src/core/directives/user-link.ts @@ -0,0 +1,64 @@ +// (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 { ActivatedRoute } from '@angular/router'; +import { NavController } from '@ionic/angular'; +import { CoreNavHelper } from '@services/nav-helper'; + +import { CoreObject } from '@singletons/object'; + +/** + * Directive to go to user profile on click. + */ +@Directive({ + selector: '[core-user-link]', +}) +export class CoreUserLinkDirective implements OnInit { + + @Input() userId?: number; // User id to open the profile. + @Input() courseId?: number; // If set, course id to show the user info related to that course. + + protected element: HTMLElement; + + constructor( + element: ElementRef, + protected navCtrl: NavController, + protected route: ActivatedRoute, + ) { + this.element = element.nativeElement; + } + + /** + * Function executed when the component is initialized. + */ + ngOnInit(): void { + this.element.addEventListener('click', (event) => { + // If the event prevented default action, do nothing. + if (event.defaultPrevented || !this.userId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + // @todo If this directive is inside a split view, use the split view's master nav. + CoreNavHelper.instance.goInCurrentMainMenuTab('user', CoreObject.removeUndefined({ + userId: this.userId, + courseId: this.courseId, + })); + }); + } + +} diff --git a/src/core/features/contentlinks/classes/module-list-handler.ts b/src/core/features/contentlinks/classes/module-list-handler.ts index ecb929412..e6e2d663e 100644 --- a/src/core/features/contentlinks/classes/module-list-handler.ts +++ b/src/core/features/contentlinks/classes/module-list-handler.ts @@ -12,11 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreContentLinksHelper } from '../services/contentlinks-helper'; import { CoreContentLinksHandlerBase } from './base-handler'; import { Translate } from '@singletons'; import { Params } from '@angular/router'; import { CoreContentLinksAction } from '../services/contentlinks-delegate'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Handler to handle URLs pointing to a list of a certain type of modules. @@ -65,7 +65,7 @@ export class CoreContentLinksModuleListHandler extends CoreContentLinksHandlerBa title: this.title || Translate.instance.instant('addon.mod_' + this.modName + '.modulenameplural'), }; - CoreContentLinksHelper.instance.goInSite('CoreCourseListModTypePage @todo', stateParams, siteId); + CoreNavHelper.instance.goInSite('CoreCourseListModTypePage @todo', stateParams, siteId); }, }]; } diff --git a/src/core/features/contentlinks/pages/choose-site/choose-site.ts b/src/core/features/contentlinks/pages/choose-site/choose-site.ts index 818170a0b..376de7f04 100644 --- a/src/core/features/contentlinks/pages/choose-site/choose-site.ts +++ b/src/core/features/contentlinks/pages/choose-site/choose-site.ts @@ -17,11 +17,11 @@ import { NavController } from '@ionic/angular'; import { CoreSiteBasicInfo, CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { Translate } from '@singletons'; -import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreContentLinksAction } from '../../services/contentlinks-delegate'; import { CoreContentLinksHelper } from '../../services/contentlinks-helper'; import { ActivatedRoute } from '@angular/router'; import { CoreError } from '@classes/errors/error'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Page to display the list of sites to choose one to perform a content link action. @@ -102,7 +102,7 @@ export class CoreContentLinksChooseSitePage implements OnInit { */ siteClicked(siteId: string): void { if (this.isRootURL) { - CoreLoginHelper.instance.redirect('', {}, siteId); + CoreNavHelper.instance.openInSiteMainMenu('', {}, siteId); } else if (this.action) { this.action.action(siteId); } diff --git a/src/core/features/contentlinks/services/contentlinks-helper.ts b/src/core/features/contentlinks/services/contentlinks-helper.ts index 1da43ad0a..75631efce 100644 --- a/src/core/features/contentlinks/services/contentlinks-helper.ts +++ b/src/core/features/contentlinks/services/contentlinks-helper.ts @@ -16,13 +16,11 @@ import { Injectable } from '@angular/core'; import { NavController } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreUtils } from '@services/utils/utils'; -import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreContentLinksDelegate, CoreContentLinksAction } from './contentlinks-delegate'; import { CoreSite } from '@classes/site'; -import { CoreMainMenu } from '@features/mainmenu/services/mainmenu'; -import { makeSingleton, NgZone, Translate } from '@singletons'; +import { makeSingleton, Translate } from '@singletons'; import { Params } from '@angular/router'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Service that provides some features regarding content links. @@ -94,50 +92,10 @@ export class CoreContentLinksHelperProvider { * @param siteId Site ID. If not defined, current site. * @param checkMenu If true, check if the root page of a main menu tab. Only the page name will be checked. * @return Promise resolved when done. + * @deprecated since 3.9.5. Use CoreNavHelperService.goInSite instead. */ - goInSite( - pageName: string, - pageParams: Params, - siteId?: string, - checkMenu?: boolean, - ): Promise { - siteId = siteId || CoreSites.instance.getCurrentSiteId(); - - const deferred = CoreUtils.instance.promiseDefer(); - - // Execute the code in the Angular zone, so change detection doesn't stop working. - NgZone.instance.run(async () => { - try { - if (siteId == CoreSites.instance.getCurrentSiteId()) { - if (checkMenu) { - let isInMenu = false; - // Check if the page is in the main menu. - try { - isInMenu = await CoreMainMenu.instance.isCurrentMainMenuHandler(pageName); - } catch { - isInMenu = false; - } - - if (isInMenu) { - // Just select the tab. @todo test. - CoreLoginHelper.instance.loadPageInMainMenu(pageName, pageParams); - } else { - await this.navCtrl.navigateForward(pageName, { queryParams: pageParams }); - } - } else { - await this.navCtrl.navigateForward(pageName, { queryParams: pageParams }); - } - } else { - await CoreLoginHelper.instance.redirect(pageName, pageParams, siteId); - } - - deferred.resolve(); - } catch (error) { - deferred.reject(error); - } - }); - - return deferred.promise; + goInSite(pageName: string, pageParams: Params, siteId?: string, checkMenu?: boolean): Promise { + return CoreNavHelper.instance.goInSite(pageName, pageParams, siteId, checkMenu); } /** @@ -234,7 +192,7 @@ export class CoreContentLinksHelperProvider { } } else { // Login in the site. - return CoreLoginHelper.instance.redirect('', {}, site.getId()); + return CoreNavHelper.instance.openInSiteMainMenu('', {}, site.getId()); } } diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index b581aa167..81f85e2cb 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -32,9 +32,9 @@ import { } from '@features/courses/services/courses'; import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper'; import { CoreArray } from '@singletons/array'; -import { CoreLoginHelper, CoreLoginHelperProvider } from '@features/login/services/login-helper'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreCourseOffline } from './course-offline'; +import { CoreNavHelper, CoreNavHelperService } from '@services/nav-helper'; /** * Prefetch info of a module. @@ -938,7 +938,7 @@ export class CoreCourseHelperProvider { params = params || {}; Object.assign(params, { course: course }); - return CoreLoginHelper.instance.redirect(CoreLoginHelperProvider.OPEN_COURSE, params, siteId); + return CoreNavHelper.instance.openInSiteMainMenu(CoreNavHelperService.OPEN_COURSE, params, siteId); } } diff --git a/src/core/features/courses/pages/course-preview/course-preview.html b/src/core/features/courses/pages/course-preview/course-preview.html index 444ad7027..0baa7286b 100644 --- a/src/core/features/courses/pages/course-preview/course-preview.html +++ b/src/core/features/courses/pages/course-preview/course-preview.html @@ -43,19 +43,15 @@

{{ 'core.teachers' | translate }}

- +
diff --git a/src/core/features/editor/components/components.module.ts b/src/core/features/editor/components/components.module.ts new file mode 100644 index 000000000..b57a73036 --- /dev/null +++ b/src/core/features/editor/components/components.module.ts @@ -0,0 +1,42 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreEditorRichTextEditorComponent } from './rich-text-editor/rich-text-editor'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + CoreEditorRichTextEditorComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + ], + providers: [ + ], + exports: [ + CoreEditorRichTextEditorComponent, + ], + entryComponents: [ + CoreEditorRichTextEditorComponent, + ], +}) +export class CoreEditorComponentsModule {} diff --git a/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html b/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html new file mode 100644 index 000000000..a409f709c --- /dev/null +++ b/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html @@ -0,0 +1,113 @@ +
+
+
+ + + + +
+ + {{ infoMessage | translate }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss new file mode 100644 index 000000000..5f641ead3 --- /dev/null +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss @@ -0,0 +1,181 @@ +:host { + height: 40vh; + overflow: hidden; + min-height: 200px; /* Just in case vh is not supported */ + min-height: 40vh; + width: 100%; + display: flex; + flex-direction: column; + // @include darkmode() { + // background-color: $gray-darker; + // } + + .core-rte-editor-container { + max-height: calc(100% - 46px); + display: flex; + flex-direction: column; + flex-grow: 1; + &.toolbar-hidden { + max-height: 100%; + } + + .core-rte-info-message { + padding: 5px; + border-top: 1px solid var(--ion-color-secondary); + background: white; + flex-shrink: 1; + font-size: 1.4rem; + + .icon { + color: var(--ion-color-secondary); + } + } + } + + .core-rte-editor, .core-textarea { + padding: 2px; + margin: 2px; + width: 100%; + resize: none; + background-color: white; + flex-grow: 1; + // @include darkmode() { + // background-color: var(--gray-darker); + // color: var(--white); + // } + } + + .core-rte-editor { + flex-grow: 1; + flex-shrink: 1; + -webkit-user-select: auto !important; + word-wrap: break-word; + overflow-x: hidden; + overflow-y: auto; + cursor: text; + img { + // @include padding(null, null, null, 2px); + max-width: 95%; + width: auto; + } + &:empty:before { + content: attr(data-placeholder-text); + display: block; + color: var(--gray-light); + font-weight: bold; + + // @include darkmode() { + // color: $gray; + // } + } + + // Make empty elements selectable (to move the cursor). + *:empty:after { + content: '\200B'; + } + } + + .core-textarea { + flex-grow: 1; + flex-shrink: 1; + position: relative; + + textarea { + margin: 0 !important; + padding: 0; + height: 100% !important; + width: 100% !important; + resize: none; + overflow-x: hidden; + overflow-y: auto; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + } + + div.core-rte-toolbar { + display: flex; + width: 100%; + z-index: 1; + flex-grow: 0; + flex-shrink: 0; + background-color: var(--white); + + // @include darkmode() { + // background-color: $black; + // } + // @include padding(5px, null); + border-top: 1px solid var(--gray); + + ion-slides { + width: 240px; + flex-grow: 1; + flex-shrink: 1; + } + + button { + display: flex; + justify-content: center; + align-items: center; + width: 36px; + height: 36px; + padding-right: 6px; + padding-left: 6px; + margin: 0 auto; + font-size: 18px; + background-color: var(--white); + border-radius: 4px; + // @include core-transition(background-color, 200ms); + color: var(--ion-text-color); + cursor: pointer; + + // @include darkmode() { + // background-color: $black; + // color: $core-dark-text-color; + // } + + &.toolbar-button-enable { + width: 100%; + } + + &:active, &[aria-pressed="true"] { + background-color: var(--gray); + // @include darkmode() { + // background-color: $gray-dark; + // } + } + + &.toolbar-arrow { + width: 28px; + flex-grow: 0; + flex-shrink: 0; + opacity: 1; + // @include core-transition(opacity, 200ms); + + &:active { + background-color: var(--white); + // @include darkmode() { + // background-color: $black; + // } + } + + &.toolbar-arrow-hidden { + opacity: 0; + } + } + } + + &.toolbar-hidden { + visibility: none; + height: 0; + border: none; + } + } +} + +:host-context(.keyboard-is-open) { + min-height: 200px; +} \ No newline at end of file diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts new file mode 100644 index 000000000..ac8e1a15b --- /dev/null +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -0,0 +1,1057 @@ +// (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, + Output, + EventEmitter, + ViewChild, + ElementRef, + OnInit, + AfterContentInit, + OnDestroy, + Optional, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { IonTextarea, IonContent, IonSlides } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreFilepool } from '@services/filepool'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { Platform, Translate } from '@singletons'; +import { CoreEventFormActionData, CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreEditorOffline } from '../../services/editor-offline'; + +/** + * Component to display a rich text editor if enabled. + * + * If enabled, this component will show a rich text editor. Otherwise it'll show a regular textarea. + * + * Example: + * + */ +@Component({ + selector: 'core-rich-text-editor', + templateUrl: 'core-editor-rich-text-editor.html', + styleUrls: ['rich-text-editor.scss'], +}) +export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentInit, OnDestroy { + + // Based on: https://github.com/judgewest2000/Ionic3RichText/ + // @todo: Anchor button, fullscreen... + // @todo: Textarea height is not being updated when editor is resized. Height is calculated if any css is changed. + + @Input() placeholder = ''; // Placeholder to set in textarea. + @Input() control?: FormControl; // Form control. + @Input() name = 'core-rich-text-editor'; // Name to set to the textarea. + @Input() component?: string; // The component to link the files to. + @Input() componentId?: number; // An ID to use in conjunction with the component. + @Input() autoSave?: boolean | string; // Whether to auto-save the contents in a draft. Defaults to true. + @Input() contextLevel?: string; // The context level of the text. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() elementId?: string; // An ID to set to the element. + @Input() draftExtraParams?: Record; // Extra params to identify the draft. + @Output() contentChanged: EventEmitter; + + @ViewChild('editor') editor?: ElementRef; // WYSIWYG editor. + @ViewChild('textarea') textarea?: IonTextarea; // Textarea editor. + @ViewChild('toolbar') toolbar?: ElementRef; + @ViewChild(IonSlides) toolbarSlides?: IonSlides; + + protected readonly DRAFT_AUTOSAVE_FREQUENCY = 30000; + protected readonly RESTORE_MESSAGE_CLEAR_TIME = 6000; + protected readonly SAVE_MESSAGE_CLEAR_TIME = 2000; + + protected element: HTMLDivElement; + protected editorElement?: HTMLDivElement; + protected kbHeight = 0; // Last known keyboard height. + protected minHeight = 200; // Minimum height of the editor. + + protected valueChangeSubscription?: Subscription; + protected keyboardObserver?: CoreEventObserver; + protected resetObserver?: CoreEventObserver; + protected initHeightInterval?: number; + protected isCurrentView = true; + protected toolbarButtonWidth = 40; + protected toolbarArrowWidth = 28; + protected pageInstance: string; + protected autoSaveInterval?: number; + protected hideMessageTimeout?: number; + protected lastDraft = ''; + protected draftWasRestored = false; + protected originalContent?: string; + protected resizeFunction?: () => Promise; + protected selectionChangeFunction?: () => void; + protected languageChangedSubscription?: Subscription; + + rteEnabled = false; + isPhone = false; + toolbarHidden = false; + toolbarArrows = false; + toolbarPrevHidden = true; + toolbarNextHidden = false; + canScanQR = false; + infoMessage?: string; + direction = 'ltr'; + toolbarStyles = { + strong: 'false', + em: 'false', + u: 'false', + strike: 'false', + p: 'false', + h3: 'false', + h4: 'false', + h5: 'false', + ul: 'false', + ol: 'false', + }; + + slidesOpts = { + initialSlide: 0, + slidesPerView: 6, + centerInsufficientSlides: true, + }; + + constructor( + @Optional() protected content: IonContent, + elementRef: ElementRef, + ) { + this.contentChanged = new EventEmitter(); + this.element = elementRef.nativeElement as HTMLDivElement; + this.pageInstance = 'app_' + Date.now(); // Generate a "unique" ID based on timestamp. + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.canScanQR = CoreUtils.instance.canScanQR(); + this.isPhone = Platform.instance.is('mobile') && !Platform.instance.is('tablet'); + this.toolbarHidden = this.isPhone; + this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; + } + + /** + * Init editor. + */ + async ngAfterContentInit(): Promise { + this.rteEnabled = await CoreDomUtils.instance.isRichTextEditorEnabled(); + + // Setup the editor. + this.editorElement = this.editor?.nativeElement as HTMLDivElement; + this.setContent(this.control?.value); + this.originalContent = this.control?.value; + this.lastDraft = this.control?.value; + this.editorElement.onchange = this.onChange.bind(this); + this.editorElement.onkeyup = this.onChange.bind(this); + this.editorElement.onpaste = this.onChange.bind(this); + this.editorElement.oninput = this.onChange.bind(this); + this.editorElement.onkeydown = this.moveCursor.bind(this); + + // Use paragraph on enter. + document.execCommand('DefaultParagraphSeparator', false, 'p'); + + let i = 0; + this.initHeightInterval = window.setInterval(async () => { + const height = await this.maximizeEditorSize(); + if (i >= 5 || height != 0) { + clearInterval(this.initHeightInterval); + } + i++; + }, 750); + + this.setListeners(); + this.updateToolbarButtons(); + + if (this.elementId) { + // Prepend elementId with 'id_' like in web. Don't use a setter for this because the value shouldn't change. + this.elementId = 'id_' + this.elementId; + this.element.setAttribute('id', this.elementId); + } + + // Update tags for a11y. + this.replaceTags('b', 'strong'); + this.replaceTags('i', 'em'); + + if (this.shouldAutoSaveDrafts()) { + this.restoreDraft(); + + this.autoSaveDrafts(); + + this.deleteDraftOnSubmitOrCancel(); + } + } + + /** + * Set listeners and observers. + */ + protected setListeners(): void { + // Listen for changes on the control to update the editor (if it is updated from outside of this component). + this.valueChangeSubscription = this.control?.valueChanges.subscribe((newValue) => { + if (this.draftWasRestored && this.originalContent == newValue) { + // A draft was restored and the content hasn't changed in the site. Use the draft value instead of this one. + this.control?.setValue(this.lastDraft, { emitEvent: false }); + + return; + } + + // Apply the new content. + this.setContent(newValue); + this.originalContent = newValue; + this.infoMessage = undefined; + + // Save a draft so the original content is saved. + this.lastDraft = newValue; + CoreEditorOffline.instance.saveDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + this.pageInstance, + newValue, + newValue, + ); + }); + + this.resizeFunction = this.maximizeEditorSize.bind(this); + this.selectionChangeFunction = this.updateToolbarStyles.bind(this); + window.addEventListener('resize', this.resizeFunction!); + document.addEventListener('selectionchange', this.selectionChangeFunction!); + + this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, (kbHeight: number) => { + this.kbHeight = kbHeight; + this.maximizeEditorSize(); + }); + + // Change the side when the language changes. + this.languageChangedSubscription = Translate.instance.onLangChange.subscribe(() => { + setTimeout(() => { + this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; + }); + }); + } + + /** + * Resize editor to maximize the space occupied. + * + * @return Resolved with calculated editor size. + */ + protected maximizeEditorSize(): Promise { + // this.content.resize(); + + const deferred = CoreUtils.instance.promiseDefer(); + + setTimeout(async () => { + let contentVisibleHeight = await CoreDomUtils.instance.getContentHeight(this.content); + if (!CoreApp.instance.isAndroid()) { + // In Android we ignore the keyboard height because it is not part of the web view. + contentVisibleHeight -= this.kbHeight; + } + + if (contentVisibleHeight <= 0) { + deferred.resolve(0); + + return; + } + + setTimeout(async () => { + // Editor is ready, adjust Height if needed. + let height; + + if (CoreApp.instance.isAndroid()) { + // In Android we ignore the keyboard height because it is not part of the web view. + const contentHeight = await CoreDomUtils.instance.getContentHeight(this.content); + height = contentHeight - this.getSurroundingHeight(this.element); + } else if (CoreApp.instance.isIOS() && this.kbHeight > 0 && CoreApp.instance.getPlatformMajorVersion() < 12) { + // Keyboard open in iOS 11 or previous. The window height changes when the keyboard is open. + height = window.innerHeight - this.getSurroundingHeight(this.element); + + if (this.element.getBoundingClientRect().top < 40) { + // In iOS sometimes the editor is placed below the status bar. Move the scroll a bit so it doesn't happen. + window.scrollTo(window.scrollX, window.scrollY - 40); + } + + } else { + // Header is fixed, use the content to calculate the editor height. + const contentHeight = await CoreDomUtils.instance.getContentHeight(this.content); + height = contentHeight - this.kbHeight - this.getSurroundingHeight(this.element); + } + + if (height > this.minHeight) { + this.element.style.height = CoreDomUtils.instance.formatPixelsSize(height - 1); + } else { + this.element.style.height = ''; + } + + deferred.resolve(height); + }, 100); + }, 100); + + return deferred.promise; + } + + /** + * Get the height of the surrounding elements from the current to the top element. + * + * @param element Directive DOM element to get surroundings elements from. + * @return Surrounding height in px. + */ + protected getSurroundingHeight(element: HTMLElement): number { + let height = 0; + + while (element.parentElement?.tagName != 'ION-CONTENT') { + const parent = element.parentElement!; + if (element.tagName && element.tagName != 'CORE-LOADING') { + for (let x = 0; x < parent.children.length; x++) { + const child = parent.children[x]; + if (child.tagName && child != element) { + height += CoreDomUtils.instance.getElementHeight(child, false, true, true); + } + } + } + element = parent; + } + + const computedStyle = getComputedStyle(element); + height += CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'paddingTop') + + CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'paddingBottom'); + + if (element.parentElement?.tagName == 'ION-CONTENT') { + const cs2 = getComputedStyle(element); + + height -= CoreDomUtils.instance.getComputedStyleMeasure(cs2, 'paddingTop') + + CoreDomUtils.instance.getComputedStyleMeasure(cs2, 'paddingBottom'); + } + + return height; + } + + /** + * On change function to sync with form data. + */ + onChange(): void { + if (this.rteEnabled) { + if (!this.editorElement) { + return; + } + + if (this.isNullOrWhiteSpace(this.editorElement.innerText)) { + this.clearText(); + } else { + // The textarea and the form control must receive the original URLs. + this.restoreExternalContent(); + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control?.setValue(this.editorElement.innerHTML, { emitEvent: false }); + this.control?.markAsDirty(); + if (this.textarea) { + this.textarea.value = this.editorElement.innerHTML; + } + // Treat URLs again for the editor. + this.treatExternalContent(); + } + } else { + if (!this.textarea) { + return; + } + + if (this.isNullOrWhiteSpace(this.textarea.value || '')) { + this.clearText(); + } else { + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control?.setValue(this.textarea.value, { emitEvent: false }); + this.control?.markAsDirty(); + } + } + + this.contentChanged.emit(this.control?.value); + } + + /** + * On key down function to move the cursor. + * https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div + * + * @param event The event. + */ + moveCursor(event: KeyboardEvent): void { + if (!this.rteEnabled || !this.editorElement) { + return; + } + + if (event.key != 'ArrowLeft' && event.key != 'ArrowRight') { + return; + } + + this.stopBubble(event); + + const move = event.key == 'ArrowLeft' ? -1 : +1; + const cursor = this.getCurrentCursorPosition(this.editorElement); + + this.setCurrentCursorPosition(this.editorElement, cursor + move); + } + + /** + * Returns the number of chars from the beggining where is placed the cursor. + * + * @param parent Parent where to get the position from. + * @return Position in chars. + */ + protected getCurrentCursorPosition(parent: Node): number { + const selection = window.getSelection(); + + let charCount = -1; + + if (selection?.focusNode && parent.contains(selection.focusNode)) { + let node: Node | null = selection.focusNode; + charCount = selection.focusOffset; + + while (node) { + if (node.isSameNode(parent)) { + break; + } + + if (node.previousSibling) { + node = node.previousSibling; + charCount += (node.textContent || '').length; + } else { + node = node.parentNode; + if (node === null) { + break; + } + } + } + } + + return charCount; + } + + /** + * Set the caret position on the character number. + * + * @param parent Parent where to set the position. + * @param chars Number of chars where to place the caret. If not defined it will go to the end. + */ + protected setCurrentCursorPosition(parent: Node, chars?: number): void { + /** + * Loops round all the child text nodes within the supplied node and sets a range from the start of the initial node to + * the characters. + * + * @param node Node where to start. + * @param range Previous calculated range. + * @param chars Object with counting of characters (input-output param). + * @return Selection range. + */ + const setRange = (node: Node, range: Range, chars: { count: number }): Range => { + if (chars.count === 0) { + range.setEnd(node, 0); + } else if (node && chars.count > 0) { + if (node.hasChildNodes()) { + // Navigate through children. + for (let lp = 0; lp < node.childNodes.length; lp++) { + range = setRange(node.childNodes[lp], range, chars); + + if (chars.count === 0) { + break; + } + } + } else if ((node.textContent || '').length < chars.count) { + // Jump this node. + // @todo: empty nodes will be omitted. + chars.count -= (node.textContent || '').length; + } else { + // The cursor will be placed in this element. + range.setEnd(node, chars.count); + chars.count = 0; + } + } + + return range; + }; + + let range = document.createRange(); + if (typeof chars === 'undefined') { + // Select all so it will go to the end. + range.selectNode(parent); + range.selectNodeContents(parent); + } else if (chars < 0 || chars > (parent.textContent || '').length) { + return; + } else { + range.selectNode(parent); + range.setStart(parent, 0); + range = setRange(parent, range, { count: chars }); + } + + if (range) { + const selection = window.getSelection(); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } + + /** + * Toggle from rte editor to textarea syncing values. + * + * @param event The event. + */ + async toggleEditor(event: Event): Promise { + this.stopBubble(event); + + this.setContent(this.control?.value || ''); + + this.rteEnabled = !this.rteEnabled; + + // Set focus and cursor at the end. + // Modify the DOM directly so the keyboard stays open. + if (this.rteEnabled) { + // Update tags for a11y. + this.replaceTags('b', 'strong'); + this.replaceTags('i', 'em'); + this.editorElement?.removeAttribute('hidden'); + const textareaInputElement = await this.textarea?.getInputElement(); + textareaInputElement?.setAttribute('hidden', ''); + this.editorElement?.focus(); + } else { + this.editorElement?.setAttribute('hidden', ''); + const textareaInputElement = await this.textarea?.getInputElement(); + textareaInputElement?.removeAttribute('hidden'); + this.textarea?.setFocus(); + } + } + + /** + * Treat elements that can contain external content. + * We only search for images because the editor should receive unfiltered text, so the multimedia filter won't be applied. + * Treating videos and audios in here is complex, so if a user manually adds one he won't be able to play it in the editor. + */ + protected treatExternalContent(): void { + if (!CoreSites.instance.isLoggedIn() || !this.editorElement) { + // Only treat external content if the user is logged in. + return; + } + + const elements = Array.from(this.editorElement.querySelectorAll('img')); + const siteId = CoreSites.instance.getCurrentSiteId(); + const canDownloadFiles = CoreSites.instance.getCurrentSite()!.canDownloadFiles(); + elements.forEach(async (el) => { + if (el.getAttribute('data-original-src')) { + // Already treated. + return; + } + + const url = el.src; + + if (!url || !CoreUrlUtils.instance.isDownloadableUrl(url) || + (!canDownloadFiles && CoreUrlUtils.instance.isPluginFileUrl(url))) { + // Nothing to treat. + return; + } + + // Check if it's downloaded. + const finalUrl = await CoreFilepool.instance.getSrcByUrl(siteId, url, this.component, this.componentId); + + // Check again if it's already treated, this function can be called concurrently more than once. + if (!el.getAttribute('data-original-src')) { + el.setAttribute('data-original-src', el.src); + el.setAttribute('src', finalUrl); + } + }); + } + + /** + * Reverts changes made by treatExternalContent. + */ + protected restoreExternalContent(): void { + if (!this.editorElement) { + return; + } + + const elements = Array.from(this.editorElement.querySelectorAll('img')); + elements.forEach((el) => { + const originalUrl = el.getAttribute('data-original-src'); + if (originalUrl) { + el.setAttribute('src', originalUrl); + el.removeAttribute('data-original-src'); + } + }); + } + + /** + * Check if text is empty. + * + * @param value text + */ + protected isNullOrWhiteSpace(value: string | null): boolean { + if (value == null || typeof value == 'undefined') { + return true; + } + + value = value.replace(/[\n\r]/g, ''); + value = value.split(' ').join(''); + + return value.length === 0; + } + + /** + * Set the content of the textarea and the editor element. + * + * @param value New content. + */ + protected setContent(value: string | null): void { + if (!this.editorElement || !this.textarea) { + return; + } + + if (this.isNullOrWhiteSpace(value)) { + this.editorElement.innerHTML = '

'; + this.textarea.value = ''; + } else { + this.editorElement.innerHTML = value!; + this.textarea.value = value; + this.treatExternalContent(); + } + } + + /** + * Clear the text. + */ + clearText(): void { + this.setContent(null); + + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control?.setValue(null, { emitEvent: false }); + + setTimeout(() => { + if (this.rteEnabled && this.editorElement) { + this.setCurrentCursorPosition(this.editorElement); + } + }, 1); + } + + /** + * Execute an action over the selected text. + * API docs: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand + * + * @param event Event data + * @param command Command to execute. + * @param parameters If parameters is set to block, a formatBlock command will be performed. Otherwise it will switch the + * toolbar styles button when set. + */ + buttonAction(event: Event, command: string, parameters?: string): void { + this.stopBubble(event); + + if (!command) { + return; + } + + if (parameters == 'block') { + document.execCommand('formatBlock', false, '<' + command + '>'); + + return; + } + + if (parameters) { + this.toolbarStyles[parameters] = this.toolbarStyles[parameters] == 'true' ? 'false' : 'true'; + } + + document.execCommand(command, false); + + // Modern browsers are using non a11y tags, so replace them. + if (command == 'bold') { + this.replaceTags('b', 'strong'); + } else if (command == 'italic') { + this.replaceTags('i', 'em'); + } + } + + /** + * Replace tags for a11y. + * + * @param originTag Origin tag to be replaced. + * @param destinationTag Destination tag to replace. + */ + protected replaceTags(originTag: string, destinationTag: string): void { + if (!this.editorElement) { + return; + } + + const elems = Array.from(this.editorElement.getElementsByTagName(originTag)); + + elems.forEach((elem) => { + const newElem = document.createElement(destinationTag); + newElem.innerHTML = elem.innerHTML; + + if (elem.hasAttributes()) { + const attrs = Array.from(elem.attributes); + attrs.forEach((attr) => { + newElem.setAttribute(attr.name, attr.value); + }); + } + + elem.parentNode?.replaceChild(newElem, elem); + }); + + this.onChange(); + } + + /** + * Focus editor when click the area. + */ + focusRTE(): void { + if (this.rteEnabled) { + this.editorElement?.focus(); + } else { + this.textarea?.setFocus(); + } + } + + /** + * Hide the toolbar in phone mode. + */ + hideToolbar(event: Event): void { + this.stopBubble(event); + + if (this.isPhone) { + this.toolbarHidden = true; + } + } + + /** + * Show the toolbar. + */ + showToolbar(event: Event): void { + this.stopBubble(event); + + this.editorElement?.focus(); + this.toolbarHidden = false; + } + + /** + * Stop event default and propagation. + * + * @param event Event. + */ + stopBubble(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + } + + /** + * When a button is clicked first we should stop event propagation, but it has some cases to not. + * + * @param event Event. + */ + mouseDownAction(event: Event): void { + const selection = window.getSelection()?.toString(); + + // When RTE is focused with a whole paragraph in desktop the stopBubble will not fire click. + if (CoreApp.instance.isMobile() || !this.rteEnabled || document.activeElement != this.editorElement || selection == '') { + this.stopBubble(event); + } + } + + /** + * Method that shows the next toolbar buttons. + */ + async toolbarNext(event: Event): Promise { + this.stopBubble(event); + + if (!this.toolbarNextHidden) { + const currentIndex = await this.toolbarSlides?.getActiveIndex(); + this.toolbarSlides?.slideTo((currentIndex || 0) + this.slidesOpts.slidesPerView); + } + + await this.updateToolbarArrows(); + } + + /** + * Method that shows the previous toolbar buttons. + */ + async toolbarPrev(event: Event): Promise { + this.stopBubble(event); + + if (!this.toolbarPrevHidden) { + const currentIndex = await this.toolbarSlides?.getActiveIndex(); + this.toolbarSlides?.slideTo((currentIndex || 0) - this.slidesOpts.slidesPerView); + } + + await this.updateToolbarArrows(); + } + + /** + * Update the number of toolbar buttons displayed. + */ + async updateToolbarButtons(): Promise { + if (!this.isCurrentView || !this.toolbar || !this.toolbarSlides) { + // Don't calculate if component isn't in current view, the calculations are wrong. + return; + } + + const length = await this.toolbarSlides.length(); + + const width = CoreDomUtils.instance.getElementWidth(this.toolbar.nativeElement); + + if (!width) { + // Width is not available yet, try later. + setTimeout(this.updateToolbarButtons.bind(this), 100); + + return; + } + + if (width > length * this.toolbarButtonWidth) { + this.slidesOpts = { ...this.slidesOpts, slidesPerView: length }; + this.toolbarArrows = false; + } else { + const slidesPerView = Math.floor((width - this.toolbarArrowWidth * 2) / this.toolbarButtonWidth); + this.slidesOpts = { ...this.slidesOpts, slidesPerView }; + this.toolbarArrows = true; + } + + await this.toolbarSlides.update(); + + await this.updateToolbarArrows(); + } + + /** + * Show or hide next/previous toolbar arrows. + */ + async updateToolbarArrows(): Promise { + if (!this.toolbarSlides) { + return; + } + + const currentIndex = await this.toolbarSlides.getActiveIndex(); + const length = await this.toolbarSlides.length(); + this.toolbarPrevHidden = currentIndex <= 0; + this.toolbarNextHidden = currentIndex + this.slidesOpts.slidesPerView >= length; + } + + /** + * Update highlighted toolbar styles. + */ + updateToolbarStyles(): void { + const node = window.getSelection()?.focusNode; + if (!node) { + return; + } + + let element = node.nodeType == 1 ? node as HTMLElement : node.parentElement; + const styles = {}; + + while (element != null && element !== this.editorElement) { + const tagName = element.tagName.toLowerCase(); + if (this.toolbarStyles[tagName]) { + styles[tagName] = 'true'; + } + element = element.parentElement; + } + + for (const tagName in this.toolbarStyles) { + this.toolbarStyles[tagName] = 'false'; + } + + if (element === this.editorElement) { + Object.assign(this.toolbarStyles, styles); + } + } + + /** + * Check if should auto save drafts. + * + * @return {boolean} Whether it should auto save drafts. + */ + protected shouldAutoSaveDrafts(): boolean { + return !!CoreSites.instance.getCurrentSite() && + (typeof this.autoSave == 'undefined' || CoreUtils.instance.isTrueOrOne(this.autoSave)) && + typeof this.contextLevel != 'undefined' && + typeof this.contextInstanceId != 'undefined' && + typeof this.elementId != 'undefined'; + } + + /** + * Restore a draft if there is any. + * + * @return Promise resolved when done. + */ + protected async restoreDraft(): Promise { + try { + const entry = await CoreEditorOffline.instance.resumeDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + this.pageInstance, + this.originalContent, + ); + + if (typeof entry == 'undefined') { + // No draft found. + return; + } + + let draftText = entry.drafttext || ''; + + // Revert untouched editor contents to an empty string. + if (draftText == '

' || draftText == '


' || draftText == '
' || + draftText == '

 

' || draftText == '


 

') { + draftText = ''; + } + + if (draftText !== '' && this.control && draftText != this.control.value) { + // Restore the draft. + this.control.setValue(draftText, { emitEvent: false }); + this.setContent(draftText); + this.lastDraft = draftText; + this.draftWasRestored = true; + this.originalContent = entry.originalcontent; + + if (entry.drafttext != entry.originalcontent) { + // Notify the user. + this.showMessage('core.editor.textrecovered', this.RESTORE_MESSAGE_CLEAR_TIME); + } + } + } catch (error) { + // Ignore errors, shouldn't happen. + } + } + + /** + * Automatically save drafts every certain time. + */ + protected autoSaveDrafts(): void { + this.autoSaveInterval = window.setInterval(async () => { + if (!this.control) { + return; + } + + const newText = this.control.value; + + if (this.lastDraft == newText) { + // Text hasn't changed, nothing to save. + return; + } + + try { + await CoreEditorOffline.instance.saveDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + this.pageInstance, + newText, + this.originalContent, + ); + + // Draft saved, notify the user. + this.lastDraft = newText; + this.showMessage('core.editor.autosavesucceeded', this.SAVE_MESSAGE_CLEAR_TIME); + } catch (error) { + // Error saving draft. + } + }, this.DRAFT_AUTOSAVE_FREQUENCY); + } + + /** + * Delete the draft when the form is submitted or cancelled. + */ + protected deleteDraftOnSubmitOrCancel(): void { + this.resetObserver = CoreEvents.on(CoreEvents.FORM_ACTION, async (data: CoreEventFormActionData) => { + const form = this.element.closest('form'); + + if (data.form && form && data.form == form) { + try { + await CoreEditorOffline.instance.deleteDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + ); + } catch (error) { + // Error deleting draft. Shouldn't happen. + } + } + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Show a message. + * + * @param message Identifier of the message to display. + * @param timeout Number of milliseconds when to remove the message. + */ + protected showMessage(message: string, timeout: number): void { + clearTimeout(this.hideMessageTimeout); + + this.infoMessage = message; + + this.hideMessageTimeout = window.setTimeout(() => { + this.hideMessageTimeout = undefined; + this.infoMessage = undefined; + }, timeout); + } + + /** + * Scan a QR code and put its text in the editor. + * + * @param event Event data + * @return Promise resolved when done. + */ + async scanQR(event: Event): Promise { + this.stopBubble(event); + + // Scan for a QR code. + const text = await CoreUtils.instance.scanQR(); + + if (text) { + document.execCommand('insertText', false, text); + } + // this.content.resize(); // Resize content, otherwise the content height becomes 1 for some reason. + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.isCurrentView = true; + + this.updateToolbarButtons(); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.isCurrentView = false; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.valueChangeSubscription?.unsubscribe(); + this.languageChangedSubscription?.unsubscribe(); + window.removeEventListener('resize', this.resizeFunction!); + document.removeEventListener('selectionchange', this.selectionChangeFunction!); + clearInterval(this.initHeightInterval); + clearInterval(this.autoSaveInterval); + clearTimeout(this.hideMessageTimeout); + this.resetObserver?.off(); + this.keyboardObserver?.off(); + } + +} diff --git a/src/core/features/editor/editor.module.ts b/src/core/features/editor/editor.module.ts new file mode 100644 index 000000000..012860b7c --- /dev/null +++ b/src/core/features/editor/editor.module.ts @@ -0,0 +1,35 @@ +// (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 { NgModule } from '@angular/core'; + +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CoreEditorComponentsModule } from './components/components.module'; +import { SITE_SCHEMA } from './services/database/editor'; + +@NgModule({ + declarations: [ + ], + imports: [ + CoreEditorComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA], + multi: true, + }, + ], +}) +export class CoreEditorModule {} diff --git a/src/core/features/editor/lang/en.json b/src/core/features/editor/lang/en.json new file mode 100644 index 000000000..508c6ddb0 --- /dev/null +++ b/src/core/features/editor/lang/en.json @@ -0,0 +1,17 @@ +{ + "autosavesucceeded": "Draft saved.", + "bold": "Bold", + "clear": "Clear formatting", + "h3": "Heading (large)", + "h4": "Heading (medium)", + "h5": "Heading (small)", + "hidetoolbar": "Hide toolbar", + "italic": "Italic", + "orderedlist": "Ordered list", + "p": "Paragraph", + "strike": "Strike through", + "textrecovered": "A draft version of this text was automatically restored.", + "toggle": "Toggle editor", + "underline": "Underline", + "unorderedlist": "Unordered list" +} \ No newline at end of file diff --git a/src/core/features/editor/services/database/editor.ts b/src/core/features/editor/services/database/editor.ts new file mode 100644 index 000000000..1a0eac575 --- /dev/null +++ b/src/core/features/editor/services/database/editor.ts @@ -0,0 +1,93 @@ +// (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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreEditorOffline service. + */ +export const DRAFT_TABLE = 'editor_draft'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreEditorProvider', + version: 1, + tables: [ + { + name: DRAFT_TABLE, + columns: [ + { + name: 'contextlevel', + type: 'TEXT', + }, + { + name: 'contextinstanceid', + type: 'INTEGER', + }, + { + name: 'elementid', + type: 'TEXT', + }, + { + name: 'extraparams', // Moodle web uses a page hash built with URL. App will use some params stringified. + type: 'TEXT', + }, + { + name: 'drafttext', + type: 'TEXT', + notNull: true, + }, + { + name: 'pageinstance', + type: 'TEXT', + notNull: true, + }, + { + name: 'timecreated', + type: 'INTEGER', + notNull: true, + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true, + }, + { + name: 'originalcontent', + type: 'TEXT', + }, + ], + primaryKeys: ['contextlevel', 'contextinstanceid', 'elementid', 'extraparams'], + }, + ], +}; + +/** + * Primary data to identify a stored draft. + */ +export type CoreEditorDraftPrimaryData = { + contextlevel: string; // Context level. + contextinstanceid: number; // The instance ID related to the context. + elementid: string; // Element ID. + extraparams: string; // Extra params stringified. +}; + +/** + * Draft data stored. + */ +export type CoreEditorDraft = CoreEditorDraftPrimaryData & { + drafttext?: string; // Draft text stored. + pageinstance?: string; // Unique identifier to prevent storing data from several sources at the same time. + timecreated?: number; // Time created. + timemodified?: number; // Time modified. + originalcontent?: string; // Original content of the editor. +}; diff --git a/src/core/features/editor/services/editor-offline.ts b/src/core/features/editor/services/editor-offline.ts new file mode 100644 index 000000000..f9dda9ea3 --- /dev/null +++ b/src/core/features/editor/services/editor-offline.ts @@ -0,0 +1,239 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; + +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreEditorDraft, CoreEditorDraftPrimaryData, DRAFT_TABLE } from './database/editor'; + +/** + * Service with features regarding rich text editor in offline. + */ +@Injectable({ providedIn: 'root' }) +export class CoreEditorOfflineProvider { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreEditorOfflineProvider'); + } + + /** + * Delete a draft from DB. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + siteId?: string, + ): Promise { + try { + const db = await CoreSites.instance.getSiteDb(siteId); + + const params = this.fixDraftPrimaryData(contextLevel, contextInstanceId, elementId, extraParams); + + await db.deleteRecords(DRAFT_TABLE, params); + } catch (error) { + // Ignore errors, probably no draft stored. + } + } + + /** + * Return an object with the draft primary data converted to the right format. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @return Object with the fixed primary data. + */ + protected fixDraftPrimaryData( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + ): CoreEditorDraftPrimaryData { + + return { + contextlevel: contextLevel, + contextinstanceid: contextInstanceId, + elementid: elementId, + extraparams: CoreUtils.instance.sortAndStringify(extraParams || {}), + }; + } + + /** + * Get a draft from DB. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the draft data. Undefined if no draft stored. + */ + async getDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const params = this.fixDraftPrimaryData(contextLevel, contextInstanceId, elementId, extraParams); + + return db.getRecord(DRAFT_TABLE, params); + } + + /** + * Get draft to resume it. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param pageInstance Unique identifier to prevent storing data from several sources at the same time. + * @param originalContent Original content of the editor. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the draft data. Undefined if no draft stored. + */ + async resumeDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + pageInstance: string, + originalContent?: string, + siteId?: string, + ): Promise { + + try { + // Check if there is a draft stored. + const entry = await this.getDraft(contextLevel, contextInstanceId, elementId, extraParams, siteId); + + // There is a draft stored. Update its page instance. + try { + const db = await CoreSites.instance.getSiteDb(siteId); + + entry.pageinstance = pageInstance; + entry.timemodified = Date.now(); + + if (originalContent && entry.originalcontent != originalContent) { + entry.originalcontent = originalContent; + entry.drafttext = ''; // "Discard" the draft. + } + + await db.insertRecord(DRAFT_TABLE, entry); + } catch (error) { + // Ignore errors saving the draft. It shouldn't happen. + } + + return entry; + } catch (error) { + // No draft stored. Store an empty draft to save the pageinstance. + await this.saveDraft( + contextLevel, + contextInstanceId, + elementId, + extraParams, + pageInstance, + '', + originalContent, + siteId, + ); + } + } + + /** + * Save a draft in DB. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param pageInstance Unique identifier to prevent storing data from several sources at the same time. + * @param draftText The text to store. + * @param originalContent Original content of the editor. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + pageInstance: string, + draftText: string, + originalContent?: string, + siteId?: string, + ): Promise { + + let timecreated = Date.now(); + let entry: CoreEditorDraft | undefined; + + // Check if there is a draft already stored. + try { + entry = await this.getDraft(contextLevel, contextInstanceId, elementId, extraParams, siteId); + + timecreated = entry.timecreated || timecreated; + } catch (error) { + // No draft already stored. + } + + if (entry) { + if (entry.pageinstance != pageInstance) { + this.logger.warn(`Discarding draft because of pageinstance. Context '${contextLevel}' '${contextInstanceId}', ` + + `element '${elementId}'`); + + throw new CoreError('Draft was discarded because it was modified in another page.'); + } + + if (!originalContent) { + // Original content not set, use the one in the entry. + originalContent = entry.originalcontent; + } + } + + const db = await CoreSites.instance.getSiteDb(siteId); + + const data: CoreEditorDraft = this.fixDraftPrimaryData(contextLevel, contextInstanceId, elementId, extraParams); + + data.drafttext = (draftText || '').trim(); + data.pageinstance = pageInstance; + data.timecreated = timecreated; + data.timemodified = Date.now(); + if (originalContent) { + data.originalcontent = originalContent; + } + + await db.insertRecord(DRAFT_TABLE, data); + } + +} + +export class CoreEditorOffline extends makeSingleton(CoreEditorOfflineProvider) {} diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index aaf9d1cf5..2219597d1 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -23,6 +23,7 @@ import { CoreMainMenuModule } from './mainmenu/mainmenu.module'; import { CoreSettingsModule } from './settings/settings.module'; import { CoreSiteHomeModule } from './sitehome/sitehome.module'; import { CoreTagModule } from './tag/tag.module'; +import { CoreUserModule } from './user/user.module'; @NgModule({ imports: [ @@ -35,6 +36,7 @@ import { CoreTagModule } from './tag/tag.module'; CoreSettingsModule, CoreSiteHomeModule, CoreTagModule, + CoreUserModule, ], }) export class CoreFeaturesModule {} diff --git a/src/core/features/login/pages/change-password/change-password.ts b/src/core/features/login/pages/change-password/change-password.ts index a8e180e95..9872eb67a 100644 --- a/src/core/features/login/pages/change-password/change-password.ts +++ b/src/core/features/login/pages/change-password/change-password.ts @@ -18,6 +18,7 @@ import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreLoginHelper } from '@features/login/services/login-helper'; import { Translate } from '@singletons'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Page that shows instructions to change the password. @@ -62,7 +63,7 @@ export class CoreLoginChangePasswordPage { * Login the user. */ login(): void { - CoreLoginHelper.instance.goToSiteInitialPage(); + CoreNavHelper.instance.goToSiteInitialPage(); this.changingPassword = false; } diff --git a/src/core/features/login/pages/credentials/credentials.ts b/src/core/features/login/pages/credentials/credentials.ts index 2e142adbf..118360ee3 100644 --- a/src/core/features/login/pages/credentials/credentials.ts +++ b/src/core/features/login/pages/credentials/credentials.ts @@ -26,6 +26,7 @@ import { CoreConstants } from '@/core/constants'; import { Translate } from '@singletons'; import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes/site'; import { CoreEvents } from '@singletons/events'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Page to enter the user credentials. @@ -244,7 +245,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { this.siteId = id; - await CoreLoginHelper.instance.goToSiteInitialPage({ urlToOpen: this.urlToOpen }); + await CoreNavHelper.instance.goToSiteInitialPage({ urlToOpen: this.urlToOpen }); } catch (error) { CoreLoginHelper.instance.treatUserTokenError(siteUrl, error, username, password); diff --git a/src/core/features/login/pages/email-signup/email-signup.html b/src/core/features/login/pages/email-signup/email-signup.html index bbef473a6..95378a395 100644 --- a/src/core/features/login/pages/email-signup/email-signup.html +++ b/src/core/features/login/pages/email-signup/email-signup.html @@ -172,8 +172,8 @@ {{ category.name }} - + diff --git a/src/core/features/login/pages/email-signup/email-signup.module.ts b/src/core/features/login/pages/email-signup/email-signup.module.ts index 7956c875d..b1a454123 100644 --- a/src/core/features/login/pages/email-signup/email-signup.module.ts +++ b/src/core/features/login/pages/email-signup/email-signup.module.ts @@ -21,6 +21,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreUserComponentsModule } from '@features/user/components/components.module'; import { CoreLoginEmailSignupPage } from './email-signup'; @@ -41,6 +42,7 @@ const routes: Routes = [ ReactiveFormsModule, CoreComponentsModule, CoreDirectivesModule, + CoreUserComponentsModule, ], declarations: [ CoreLoginEmailSignupPage, diff --git a/src/core/features/login/pages/email-signup/email-signup.ts b/src/core/features/login/pages/email-signup/email-signup.ts index d5e080862..ba3d36dae 100644 --- a/src/core/features/login/pages/email-signup/email-signup.ts +++ b/src/core/features/login/pages/email-signup/email-signup.ts @@ -25,6 +25,7 @@ import { CoreWS, CoreWSExternalWarning } from '@services/ws'; import { CoreConstants } from '@/core/constants'; import { Translate } from '@singletons'; import { CoreSitePublicConfigResponse } from '@classes/site'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; import { AuthEmailSignupProfileFieldsCategory, @@ -156,7 +157,7 @@ export class CoreLoginEmailSignupPage implements OnInit { if (typeof this.ageDigitalConsentVerification == 'undefined') { const result = await CoreUtils.instance.ignoreErrors( - CoreWS.instance.callAjax( + CoreWS.instance.callAjax( 'core_auth_is_age_digital_consent_verification_enabled', {}, { siteUrl: this.siteUrl }, @@ -189,7 +190,11 @@ export class CoreLoginEmailSignupPage implements OnInit { { siteUrl: this.siteUrl }, ); - // @todo userProfileFieldDelegate + if (CoreUserProfileFieldDelegate.instance.hasRequiredUnsupportedField(this.settings.profilefields)) { + this.allRequiredSupported = false; + + throw new Error(Translate.instance.instant('core.login.signuprequiredfieldnotsupported')); + } this.categories = CoreLoginHelper.instance.formatProfileFieldsForSignup(this.settings.profilefields); @@ -274,7 +279,7 @@ export class CoreLoginEmailSignupPage implements OnInit { const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); - const params: Record = { + const params: SignupUserWSParams = { username: this.signupForm.value.username.trim().toLowerCase(), password: this.signupForm.value.password, firstname: CoreTextUtils.instance.cleanTags(this.signupForm.value.firstname), @@ -295,8 +300,15 @@ export class CoreLoginEmailSignupPage implements OnInit { } try { - // @todo Get the data for the custom profile fields. - const result = await CoreWS.instance.callAjax( + // Get the data for the custom profile fields. + params.customprofilefields = await CoreUserProfileFieldDelegate.instance.getDataForFields( + this.settings?.profilefields, + true, + 'email', + this.signupForm.value, + ); + + const result = await CoreWS.instance.callAjax( 'auth_email_signup_user', params, { siteUrl: this.siteUrl }, @@ -376,7 +388,7 @@ export class CoreLoginEmailSignupPage implements OnInit { params.age = parseInt(params.age, 10); // Use just the integer part. try { - const result = await CoreWS.instance.callAjax('core_auth_is_minor', params, { siteUrl: this.siteUrl }); + const result = await CoreWS.instance.callAjax('core_auth_is_minor', params, { siteUrl: this.siteUrl }); CoreDomUtils.instance.triggerFormSubmittedEvent(this.ageFormElement, true); @@ -404,14 +416,35 @@ export class CoreLoginEmailSignupPage implements OnInit { /** * Result of WS core_auth_is_age_digital_consent_verification_enabled. */ -export type IsAgeVerificationEnabledResponse = { +type IsAgeVerificationEnabledWSResponse = { status: boolean; // True if digital consent verification is enabled, false otherwise. }; +/** + * Params for WS auth_email_signup_user. + */ +type SignupUserWSParams = { + username: string; // Username. + password: string; // Plain text password. + firstname: string; // The first name(s) of the user. + lastname: string; // The family name of the user. + email: string; // A valid and unique email address. + city?: string; // Home city of the user. + country?: string; // Home country code. + recaptchachallengehash?: string; // Recaptcha challenge hash. + recaptcharesponse?: string; // Recaptcha response. + customprofilefields?: { // User custom fields (also known as user profile fields). + type: string; // The type of the custom field. + name: string; // The name of the custom field. + value: unknown; // Custom field value, can be an encoded json if required. + }[]; + redirect?: string; // Redirect the user to this site url after confirmation. +}; + /** * Result of WS auth_email_signup_user. */ -export type SignupUserResult = { +type SignupUserWSResult = { success: boolean; // True if the user was created false otherwise. warnings?: CoreWSExternalWarning[]; }; @@ -419,6 +452,6 @@ export type SignupUserResult = { /** * Result of WS core_auth_is_minor. */ -export type IsMinorResult = { +type IsMinorWSResult = { status: boolean; // True if the user is considered to be a digital minor, false if not. }; diff --git a/src/core/features/login/pages/init/init.ts b/src/core/features/login/pages/init/init.ts index a1f726fac..97f89a85a 100644 --- a/src/core/features/login/pages/init/init.ts +++ b/src/core/features/login/pages/init/init.ts @@ -20,6 +20,7 @@ import { ApplicationInit, SplashScreen } from '@singletons'; import { CoreConstants } from '@/core/constants'; import { CoreSites } from '@services/sites'; import { CoreLoginHelper } from '@features/login/services/login-helper'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Page that displays a "splash screen" while the app is being initialized. @@ -79,7 +80,7 @@ export class CoreLoginInitPage implements OnInit { return; } - return CoreLoginHelper.instance.goToSiteInitialPage({ + return CoreNavHelper.instance.goToSiteInitialPage({ redirectPage: redirectData.page, redirectParams: redirectData.params, }); @@ -89,7 +90,7 @@ export class CoreLoginInitPage implements OnInit { } } else if (redirectData.page) { // No site to load, open the page. - return CoreLoginHelper.instance.goToNoSitePage(redirectData.page, redirectData.params); + return CoreNavHelper.instance.goToNoSitePage(redirectData.page, redirectData.params); } } @@ -109,7 +110,7 @@ export class CoreLoginInitPage implements OnInit { return this.loadPage(); } - return CoreLoginHelper.instance.goToSiteInitialPage(); + return CoreNavHelper.instance.goToSiteInitialPage(); } await this.navCtrl.navigateRoot('/login/sites'); diff --git a/src/core/features/login/pages/reconnect/reconnect.ts b/src/core/features/login/pages/reconnect/reconnect.ts index eee6daf88..729bcd50b 100644 --- a/src/core/features/login/pages/reconnect/reconnect.ts +++ b/src/core/features/login/pages/reconnect/reconnect.ts @@ -25,6 +25,7 @@ import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes/site'; import { CoreEvents } from '@singletons/events'; import { CoreError } from '@classes/errors/error'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Page to enter the user password to reconnect to a site. @@ -206,7 +207,7 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy { this.credForm.controls['password'].reset(); // Go to the site initial page. - await CoreLoginHelper.instance.goToSiteInitialPage({ + await CoreNavHelper.instance.goToSiteInitialPage({ redirectPage: this.page, redirectParams: this.pageParams, }); diff --git a/src/core/features/login/pages/site-policy/site-policy.ts b/src/core/features/login/pages/site-policy/site-policy.ts index fb6e31988..b38591b98 100644 --- a/src/core/features/login/pages/site-policy/site-policy.ts +++ b/src/core/features/login/pages/site-policy/site-policy.ts @@ -22,6 +22,7 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreSite } from '@classes/site'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Page to accept a site policy. @@ -128,7 +129,7 @@ export class CoreLoginSitePolicyPage implements OnInit { // Invalidate cache since some WS don't return error if site policy is not accepted. await CoreUtils.instance.ignoreErrors(this.currentSite!.invalidateWsCache()); - await CoreLoginHelper.instance.goToSiteInitialPage(); + await CoreNavHelper.instance.goToSiteInitialPage(); } catch (error) { CoreDomUtils.instance.showErrorModalDefault(error, 'Error accepting site policy.'); } finally { diff --git a/src/core/features/login/pages/site/site.ts b/src/core/features/login/pages/site/site.ts index 5abfb1859..7d60e9871 100644 --- a/src/core/features/login/pages/site/site.ts +++ b/src/core/features/login/pages/site/site.ts @@ -31,6 +31,7 @@ import { CoreUrl } from '@singletons/url'; import { CoreUrlUtils } from '@services/utils/url'; import { CoreLoginSiteHelpComponent } from '@features/login/components/site-help/site-help'; import { CoreLoginSiteOnboardingComponent } from '@features/login/components/site-onboarding/site-onboarding'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Page that displays a "splash screen" while the app is being initialized. @@ -328,7 +329,7 @@ export class CoreLoginSitePage implements OnInit { CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); - return CoreLoginHelper.instance.goToSiteInitialPage(); + return CoreNavHelper.instance.goToSiteInitialPage(); } catch (error) { CoreLoginHelper.instance.treatUserTokenError(siteData.url, error, siteData.username, siteData.password); diff --git a/src/core/features/login/pages/sites/sites.ts b/src/core/features/login/pages/sites/sites.ts index 99de3b67b..276bb6a94 100644 --- a/src/core/features/login/pages/sites/sites.ts +++ b/src/core/features/login/pages/sites/sites.ts @@ -19,6 +19,7 @@ import { Component, OnInit } from '@angular/core'; import { CoreSiteBasicInfo, CoreSites } from '@services/sites'; import { CoreLogger } from '@singletons/logger'; import { CoreLoginHelper } from '@features/login/services/login-helper'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Page that displays a "splash screen" while the app is being initialized. @@ -124,7 +125,7 @@ export class CoreLoginSitesPage implements OnInit { const loggedIn = await CoreSites.instance.loadSite(siteId); if (loggedIn) { - return CoreLoginHelper.instance.goToSiteInitialPage(); + return CoreNavHelper.instance.goToSiteInitialPage(); } } catch (error) { this.logger.error('Error loading site ' + siteId, error); diff --git a/src/core/features/login/services/login-helper.ts b/src/core/features/login/services/login-helper.ts index fbb3be5e1..1b50a1c7e 100644 --- a/src/core/features/login/services/login-helper.ts +++ b/src/core/features/login/services/login-helper.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Location } from '@angular/common'; import { Params } from '@angular/router'; import { NavController } from '@ionic/angular'; import { Md5 } from 'ts-md5/dist/md5'; @@ -34,8 +33,8 @@ import { CoreWSError } from '@classes/errors/wserror'; import { makeSingleton, Translate } from '@singletons'; import { CoreLogger } from '@singletons/logger'; import { CoreUrl } from '@singletons/url'; -import { NavigationOptions } from '@ionic/angular/providers/nav-controller'; import { CoreObject } from '@singletons/object'; +import { CoreNavHelper, CoreNavHelperOpenMainMenuOptions, CoreNavHelperService } from '@services/nav-helper'; /** * Helper provider that provides some common features regarding authentication. @@ -43,7 +42,7 @@ import { CoreObject } from '@singletons/object'; @Injectable({ providedIn: 'root' }) export class CoreLoginHelperProvider { - static readonly OPEN_COURSE = 'open_course'; + static readonly OPEN_COURSE = CoreNavHelperService.OPEN_COURSE; // @deprecated since 3.9.5. static readonly ONBOARDING_DONE = 'onboarding_done'; static readonly FAQ_URL_IMAGE_HTML = ''; static readonly FAQ_QRCODE_IMAGE_HTML = ''; @@ -51,24 +50,13 @@ export class CoreLoginHelperProvider { protected logger: CoreLogger; protected isSSOConfirmShown = false; protected isOpenEditAlertShown = false; - protected pageToLoad?: {page: string; params?: Params; time: number}; // Page to load once main menu is opened. protected isOpeningReconnect = false; waitingForBrowser = false; constructor( - protected location: Location, protected navCtrl: NavController, ) { this.logger = CoreLogger.getInstance('CoreLoginHelper'); - - CoreEvents.on(CoreEvents.MAIN_MENU_OPEN, () => { - /* If there is any page pending to be opened, do it now. Don't open pages stored more than 5 seconds ago, probably - the function to open the page was called when it shouldn't. */ - if (this.pageToLoad && Date.now() - this.pageToLoad.time < 5000) { - this.loadPageInMainMenu(this.pageToLoad.page, this.pageToLoad.params); - delete this.pageToLoad; - } - }); } /** @@ -123,7 +111,7 @@ export class CoreLoginHelperProvider { */ checkLogout(): void { const currentSite = CoreSites.instance.getCurrentSite(); - const currentPage = CoreApp.instance.getCurrentPage(); + const currentPage = CoreNavHelper.instance.getCurrentPage(); if (!CoreApp.instance.isSSOAuthenticationOngoing() && currentSite?.isLoggedOut() && currentPage == '/login/reconnect') { // User must reauthenticate but he closed the InAppBrowser without doing so, logout him. @@ -448,39 +436,10 @@ export class CoreLoginHelperProvider { * @param page Page to open. * @param params Params of the page. * @return Promise resolved when done. + * @deprecated since 3.9.5. Use CoreNavHelperService.goToNoSitePage instead. */ - async goToNoSitePage(page: string, params?: Params): Promise { - const currentPage = CoreApp.instance.getCurrentPage(); - - if (currentPage == page) { - // Already at page, nothing to do. - } else if (page == '/login/sites') { - // Just open the page as root. - await this.navCtrl.navigateRoot(page, { queryParams: params }); - } else if (page == '/login/credentials' && currentPage == '/login/site') { - // Just open the new page to keep the navigation history. - await this.navCtrl.navigateForward(page, { queryParams: params }); - } else { - // Check if there is any site stored. - const hasSites = await CoreSites.instance.hasSites(); - - if (!hasSites) { - // There are sites stored, open sites page first to be able to go back. - await this.navCtrl.navigateRoot('/login/sites'); - - await this.navCtrl.navigateForward(page, { queryParams: params }); - } else { - if (page != '/login/site') { - // Open the new site page to be able to go back. - await this.navCtrl.navigateRoot('/login/site'); - - await this.navCtrl.navigateForward(page, { queryParams: params }); - } else { - // Just open the page as root. - await this.navCtrl.navigateRoot(page, { queryParams: params }); - } - } - } + goToNoSitePage(page: string, params?: Params): Promise { + return CoreNavHelper.instance.goToNoSitePage(page, params); } /** @@ -488,9 +447,10 @@ export class CoreLoginHelperProvider { * * @param options Options. * @return Promise resolved when done. + * @deprecated since 3.9.5. Use CoreNavHelperService.goToSiteInitialPage instead. */ - goToSiteInitialPage(options?: OpenMainMenuOptions): Promise { - return this.openMainMenu(options); + goToSiteInitialPage(options?: CoreNavHelperOpenMainMenuOptions): Promise { + return CoreNavHelper.instance.goToSiteInitialPage(options); } /** @@ -641,97 +601,15 @@ export class CoreLoginHelperProvider { return code == CoreConstants.LOGIN_SSO_CODE || code == CoreConstants.LOGIN_SSO_INAPP_CODE; } - /** - * Load a site and load a certain page in that site. - * - * @param siteId Site to load. - * @param page Name of the page to load. - * @param params Params to pass to the page. - * @return Promise resolved when done. - */ - protected async loadSiteAndPage(siteId: string, page: string, params?: Params): Promise { - if (siteId == CoreConstants.NO_SITE_ID) { - // Page doesn't belong to a site, just load the page. - await this.navCtrl.navigateRoot(page, params); - - return; - } - - const modal = await CoreDomUtils.instance.showModalLoading(); - - try { - const loggedIn = await CoreSites.instance.loadSite(siteId, page, params); - - if (!loggedIn) { - return; - } - - await this.openMainMenu({ - redirectPage: page, - redirectParams: params, - }); - } catch (error) { - // Site doesn't exist. - await this.navCtrl.navigateRoot('/login/sites'); - } finally { - modal.dismiss(); - } - } - /** * Load a certain page in the main menu page. * * @param page Name of the page to load. * @param params Params to pass to the page. + * @deprecated since 3.9.5. Use CoreNavHelperService.loadPageInMainMenu instead. */ loadPageInMainMenu(page: string, params?: Params): void { - if (!CoreApp.instance.isMainMenuOpen()) { - // Main menu not open. Store the page to be loaded later. - this.pageToLoad = { - page: page, - params: params, - time: Date.now(), - }; - - return; - } - - if (page == CoreLoginHelperProvider.OPEN_COURSE) { - // @todo Use the openCourse function. - } else { - CoreEvents.trigger(CoreEvents.LOAD_PAGE_MAIN_MENU, { redirectPage: page, redirectParams: params }); - } - } - - /** - * Open the main menu, loading a certain page. - * - * @param options Options. - * @return Promise resolved when done. - */ - protected async openMainMenu(options?: OpenMainMenuOptions): Promise { - - // Due to DeepLinker, we need to remove the path from the URL before going to main menu. - // IonTabs checks the URL to determine which path to load for deep linking, so we clear the URL. - // @todo this.location.replaceState(''); - - if (options?.redirectPage == CoreLoginHelperProvider.OPEN_COURSE) { - // Load the main menu first, and then open the course. - try { - await this.navCtrl.navigateRoot('/'); - } finally { - // @todo: Open course. - } - } else { - // Open the main menu. - const queryParams: Params = Object.assign({}, options); - delete queryParams.navigationOptions; - - await this.navCtrl.navigateRoot('/', { - queryParams, - ...options?.navigationOptions, - }); - } + CoreNavHelper.instance.loadPageInMainMenu(page, params); } /** @@ -889,7 +767,7 @@ export class CoreLoginHelperProvider { return; // Site that triggered the event is not current site. } - const currentPage = CoreApp.instance.getCurrentPage(); + const currentPage = CoreNavHelper.instance.getCurrentPage(); // If current page is already change password, stop. if (currentPage == '/login/changepassword') { @@ -948,31 +826,14 @@ export class CoreLoginHelperProvider { /** * Redirect to a new page, setting it as the root page and loading the right site if needed. * - * @param page Name of the page to load. Special cases: OPEN_COURSE (to open course page). + * @param page Name of the page to load. Special cases: CoreNavHelperService.OPEN_COURSE (to open course page). * @param params Params to pass to the page. * @param siteId Site to load. If not defined, current site. * @return Promise resolved when done. + * @deprecated since 3.9.5. Use CoreNavHelperService.openInSiteMainMenu instead. */ - async redirect(page: string, params?: Params, siteId?: string): Promise { - siteId = siteId || CoreSites.instance.getCurrentSiteId(); - - if (CoreSites.instance.isLoggedIn()) { - if (siteId && siteId != CoreSites.instance.getCurrentSiteId()) { - // Target page belongs to a different site. Change site. - // @todo: Check site plugins. - await CoreSites.instance.logout(); - - await this.loadSiteAndPage(siteId, page, params); - } else { - this.loadPageInMainMenu(page, params); - } - } else { - if (siteId) { - await this.loadSiteAndPage(siteId, page, params); - } else { - await this.navCtrl.navigateRoot('/login/sites'); - } - } + redirect(page: string, params?: Params, siteId?: string): Promise { + return CoreNavHelper.instance.openInSiteMainMenu(page, params, siteId); } /** @@ -1098,7 +959,7 @@ export class CoreLoginHelperProvider { const info = currentSite.getInfo(); if (typeof info != 'undefined' && typeof info.username != 'undefined' && !this.isOpeningReconnect) { // If current page is already reconnect, stop. - if (CoreApp.instance.getCurrentPage() == '/login/reconnect') { + if (CoreNavHelper.instance.getCurrentPage() == '/login/reconnect') { return; } @@ -1266,7 +1127,7 @@ export class CoreLoginHelperProvider { } // If current page is already site policy, stop. - if (CoreApp.instance.getCurrentPage() == '/login/sitepolicy') { + if (CoreNavHelper.instance.getCurrentPage() == '/login/sitepolicy') { return; } @@ -1474,10 +1335,3 @@ type StoredLoginLaunchData = { pageParams: Params; ssoUrlParams: CoreUrlParams; }; - -type OpenMainMenuOptions = { - redirectPage?: string; // Route of the page to open in main menu. If not defined, default tab will be selected. - redirectParams?: Params; // Params to pass to the selected tab if any. - urlToOpen?: string; // URL to open once the main menu is loaded. - navigationOptions?: NavigationOptions; // Navigation options. -}; diff --git a/src/core/features/mainmenu/mainmenu-tab-routing.module.ts b/src/core/features/mainmenu/mainmenu-tab-routing.module.ts new file mode 100644 index 000000000..d673d2a34 --- /dev/null +++ b/src/core/features/mainmenu/mainmenu-tab-routing.module.ts @@ -0,0 +1,47 @@ +// (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 { InjectionToken, Injector, ModuleWithProviders, NgModule } from '@angular/core'; +import { Route, Routes } from '@angular/router'; + +import { ModuleRoutes, resolveModuleRoutes } from '@/app/app-routing.module'; + +export const MAIN_MENU_TAB_ROUTES = new InjectionToken('MAIN_MENU_TAB_ROUTES'); + +export function buildTabMainRoutes(injector: Injector, mainRoute: Route): Routes { + const routes = resolveModuleRoutes(injector, MAIN_MENU_TAB_ROUTES); + + mainRoute.path = mainRoute.path || ''; + mainRoute.children = mainRoute.children || []; + mainRoute.children.concat(routes.children); + + return [ + mainRoute, + ...routes.siblings, + ]; +} + +@NgModule() +export class CoreMainMenuTabRoutingModule { + + static forChild(routes: Partial): ModuleWithProviders { + return { + ngModule: CoreMainMenuTabRoutingModule, + providers: [ + { provide: MAIN_MENU_TAB_ROUTES, multi: true, useValue: routes }, + ], + }; + } + +} diff --git a/src/core/features/mainmenu/pages/home/home.module.ts b/src/core/features/mainmenu/pages/home/home.module.ts index 674de5397..c61770912 100644 --- a/src/core/features/mainmenu/pages/home/home.module.ts +++ b/src/core/features/mainmenu/pages/home/home.module.ts @@ -24,16 +24,17 @@ import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreMainMenuHomePage } from './home'; import { MAIN_MENU_HOME_ROUTES } from './home-routing.module'; +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; function buildRoutes(injector: Injector): Routes { const routes = resolveModuleRoutes(injector, MAIN_MENU_HOME_ROUTES); return [ - { + ...buildTabMainRoutes(injector, { path: '', component: CoreMainMenuHomePage, children: routes.children, - }, + }), ...routes.siblings, ]; } diff --git a/src/core/features/mainmenu/pages/menu/menu.ts b/src/core/features/mainmenu/pages/menu/menu.ts index 5d58bd6d6..9e99e239e 100644 --- a/src/core/features/mainmenu/pages/menu/menu.ts +++ b/src/core/features/mainmenu/pages/menu/menu.ts @@ -25,6 +25,7 @@ import { CoreMainMenu } from '../../services/mainmenu'; import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/mainmenu-delegate'; import { CoreDomUtils } from '@services/utils/dom'; import { Translate } from '@singletons'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Page that displays the main menu of the app. @@ -60,7 +61,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { protected changeDetector: ChangeDetectorRef, protected router: Router, ) { - this.mainMenuId = CoreApp.instance.getMainMenuId(); + this.mainMenuId = CoreNavHelper.instance.getMainMenuId(); } /** @@ -131,7 +132,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { }); } - CoreApp.instance.setMainMenuOpen(this.mainMenuId, true); + CoreNavHelper.instance.setMainMenuOpen(this.mainMenuId, true); } /** @@ -226,7 +227,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { this.subscription?.unsubscribe(); this.redirectObs?.off(); window.removeEventListener('resize', this.initHandlers.bind(this)); - CoreApp.instance.setMainMenuOpen(this.mainMenuId, false); + CoreNavHelper.instance.setMainMenuOpen(this.mainMenuId, false); this.keyboardObserver?.off(); } diff --git a/src/core/features/mainmenu/pages/more/more.html b/src/core/features/mainmenu/pages/more/more.html index 4776dc9b5..aba57a084 100644 --- a/src/core/features/mainmenu/pages/more/more.html +++ b/src/core/features/mainmenu/pages/more/more.html @@ -9,8 +9,8 @@ - - + +

{{siteInfo.fullname}}

diff --git a/src/core/features/mainmenu/pages/more/more.module.ts b/src/core/features/mainmenu/pages/more/more.module.ts index b3e48f897..0893e966c 100644 --- a/src/core/features/mainmenu/pages/more/more.module.ts +++ b/src/core/features/mainmenu/pages/more/more.module.ts @@ -14,29 +14,14 @@ import { Injector, NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterModule, ROUTES, Routes } from '@angular/router'; +import { RouterModule, ROUTES } from '@angular/router'; import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; -import { resolveModuleRoutes } from '@/app/app-routing.module'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; - import { CoreMainMenuMorePage } from './more'; -import { MAIN_MENU_MORE_ROUTES } from './more-routing.module'; - -function buildRoutes(injector: Injector): Routes { - const routes = resolveModuleRoutes(injector, MAIN_MENU_MORE_ROUTES); - - return [ - { - path: '', - component: CoreMainMenuMorePage, - children: routes.children, - }, - ...routes.siblings, - ]; -} +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; @NgModule({ imports: [ @@ -47,7 +32,14 @@ function buildRoutes(injector: Injector): Routes { CoreDirectivesModule, ], providers: [ - { provide: ROUTES, multi: true, useFactory: buildRoutes, deps: [Injector] }, + { + provide: ROUTES, + multi: true, + deps: [Injector], + useFactory: (injector: Injector) => buildTabMainRoutes(injector, { + component: CoreMainMenuMorePage, + }), + }, ], declarations: [ CoreMainMenuMorePage, diff --git a/src/core/features/settings/settings.module.ts b/src/core/features/settings/settings.module.ts index 22dbbac9e..2c7fe2a4d 100644 --- a/src/core/features/settings/settings.module.ts +++ b/src/core/features/settings/settings.module.ts @@ -16,7 +16,7 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { Routes } from '@angular/router'; import { AppRoutingModule } from '@/app/app-routing.module'; -import { CoreMainMenuMoreRoutingModule } from '@features/mainmenu/pages/more/more-routing.module'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; import { CoreSettingsHelperProvider } from './services/settings-helper'; @@ -41,7 +41,7 @@ const mainMenuMoreRoutes: Routes = [ @NgModule({ imports: [ AppRoutingModule.forChild(appRoutes), - CoreMainMenuMoreRoutingModule.forChild({ siblings: mainMenuMoreRoutes }), + CoreMainMenuTabRoutingModule.forChild({ siblings: mainMenuMoreRoutes }), ], providers: [ { diff --git a/src/core/features/sitehome/services/handlers/index-link.ts b/src/core/features/sitehome/services/handlers/index-link.ts index 7faa9d8ee..567a0230a 100644 --- a/src/core/features/sitehome/services/handlers/index-link.ts +++ b/src/core/features/sitehome/services/handlers/index-link.ts @@ -16,10 +16,10 @@ import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; import { CoreSites } from '@services/sites'; import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; -import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreSiteHome } from '../sitehome'; import { makeSingleton } from '@singletons'; +import { CoreNavHelper } from '@services/nav-helper'; /** * Handler to treat links to site home index. @@ -43,7 +43,7 @@ export class CoreSiteHomeIndexLinkHandlerService extends CoreContentLinksHandler getActions(): CoreContentLinksAction[] | Promise { return [{ action: (siteId: string): void => { - CoreContentLinksHelper.instance.goInSite('sitehome', [], siteId); + CoreNavHelper.instance.goInSite('sitehome', [], siteId); }, }]; } diff --git a/src/core/features/tag/pages/index-area/index-area.html b/src/core/features/tag/pages/index-area/index-area.html index b08faa747..ca219d19d 100644 --- a/src/core/features/tag/pages/index-area/index-area.html +++ b/src/core/features/tag/pages/index-area/index-area.html @@ -13,9 +13,9 @@ - + diff --git a/src/core/features/tag/pages/index-area/index-area.page.ts b/src/core/features/tag/pages/index-area/index-area.page.ts index cdaead47a..46ea0ed39 100644 --- a/src/core/features/tag/pages/index-area/index-area.page.ts +++ b/src/core/features/tag/pages/index-area/index-area.page.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Type } from '@angular/core'; import { IonInfiniteScroll, IonRefresher } from '@ionic/angular'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTag } from '@features/tag/services/tag'; @@ -20,6 +20,7 @@ import { CoreTagFeedElement } from '../../services/tag-helper'; import { ActivatedRoute } from '@angular/router'; import { CoreTagAreaDelegate } from '../../services/tag-area-delegate'; import { Translate } from '@singletons'; +import { CoreUtils } from '@services/utils/utils'; /** * Page that displays the tag index area. @@ -47,7 +48,7 @@ export class CoreTagIndexAreaPage implements OnInit { items: CoreTagFeedElement[] = []; nextPage = 0; canLoadMore = false; - areaComponent: any; // @todo + areaComponent?: Type; loadMoreError = false; constructor( @@ -59,7 +60,7 @@ export class CoreTagIndexAreaPage implements OnInit { */ async ngOnInit(): Promise { - const navParams = this.route.snapshot.queryParamMap; + const navParams = this.route.snapshot.queryParams; this.tagId = navParams['tagId'] ? parseInt(navParams['tagId'], 10) : this.tagId; this.tagName = navParams['tagName'] || this.tagName; @@ -74,8 +75,8 @@ export class CoreTagIndexAreaPage implements OnInit { this.componentName = navParams['componentName']; this.itemType = navParams['itemType']; this.items = []; // @todo navParams['items'] || []; - this.nextPage = navParams.has('nextPage') ? parseInt(navParams['nextPage']!, 10) : 0; - this.canLoadMore = !!navParams['canLoadMore']; + this.nextPage = typeof navParams['nextPage'] != 'undefined' ? parseInt(navParams['nextPage'], 10) : 0; + this.canLoadMore = CoreUtils.instance.isTrueOrOne(navParams['canLoadMore']); try { if (!this.componentName || !this.itemType || !this.items.length || this.nextPage == 0) { diff --git a/src/core/features/tag/pages/index/index.page.ts b/src/core/features/tag/pages/index/index.page.ts index 394d1c416..9366303b1 100644 --- a/src/core/features/tag/pages/index/index.page.ts +++ b/src/core/features/tag/pages/index/index.page.ts @@ -168,8 +168,11 @@ export class CoreTagIndexPage implements OnInit { canLoadMore: area.canLoadMore, nextPage: 1, }; - // this.splitviewCtrl.push('core-tag-index-area', params); - this.router.navigate(['core-tag-index-area'], { queryParams: params }); + // this.splitviewCtrl.push('index-area', params); + this.router.navigate(['../index-area'], { + queryParams: params, + relativeTo: this.route, + }); } diff --git a/src/core/features/tag/services/handlers/index.link.ts b/src/core/features/tag/services/handlers/index.link.ts index 01bb95568..b4836cd73 100644 --- a/src/core/features/tag/services/handlers/index.link.ts +++ b/src/core/features/tag/services/handlers/index.link.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; -import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; +import { CoreNavHelper } from '@services/nav-helper'; import { makeSingleton } from '@singletons'; import { CoreTag } from '../tag'; @@ -57,11 +57,11 @@ export class CoreTagIndexLinkHandlerService extends CoreContentLinksHandlerBase }; if (!pageParams.tagId && (!pageParams.tagName || !pageParams.collectionId)) { - CoreContentLinksHelper.instance.goInSite('/main/tag/search', {}, siteId); + CoreNavHelper.instance.goInSite('/tag/search', {}, siteId); } else if (pageParams.areaId) { - CoreContentLinksHelper.instance.goInSite('/main/tag/index-area', pageParams, siteId); + CoreNavHelper.instance.goInSite('/tag/index-area', pageParams, siteId); } else { - CoreContentLinksHelper.instance.goInSite('/main/tag/index', pageParams, siteId); + CoreNavHelper.instance.goInSite('/tag/index', pageParams, siteId); } }, }]; diff --git a/src/core/features/tag/services/handlers/search.link.ts b/src/core/features/tag/services/handlers/search.link.ts index 610af58b8..382a09688 100644 --- a/src/core/features/tag/services/handlers/search.link.ts +++ b/src/core/features/tag/services/handlers/search.link.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; -import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; +import { CoreNavHelper } from '@services/nav-helper'; import { makeSingleton } from '@singletons'; import { CoreTag } from '../tag'; @@ -47,7 +47,7 @@ export class CoreTagSearchLinkHandlerService extends CoreContentLinksHandlerBase query: params.query || '', }; - CoreContentLinksHelper.instance.goInSite('/main/tag/search', pageParams, siteId); + CoreNavHelper.instance.goInSite('/tag/search', pageParams, siteId); }, }]; } diff --git a/src/core/features/tag/services/tag-area-delegate.ts b/src/core/features/tag/services/tag-area-delegate.ts index 8ef4cca71..a0daaf087 100644 --- a/src/core/features/tag/services/tag-area-delegate.ts +++ b/src/core/features/tag/services/tag-area-delegate.ts @@ -52,7 +52,7 @@ export class CoreTagAreaDelegateService extends CoreDelegate protected handlerNameProperty = 'type'; constructor() { - super('CoreTagAreaDelegate'); + super('CoreTagAreaDelegate', true); } /** diff --git a/src/core/features/tag/tag-lazy.module.ts b/src/core/features/tag/tag-lazy.module.ts index dca55c3f5..1fbe988b1 100644 --- a/src/core/features/tag/tag-lazy.module.ts +++ b/src/core/features/tag/tag-lazy.module.ts @@ -12,31 +12,42 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { Injector, NgModule } from '@angular/core'; +import { RouterModule, ROUTES, Routes } from '@angular/router'; -const routes: Routes = [ - { - path: 'index', - loadChildren: () => import('@features/tag/pages/index/index.page.module').then(m => m.CoreTagIndexPageModule), - }, - { - path: 'search', - loadChildren: () => import('@features/tag//pages/search/search.page.module').then(m => m.CoreTagSearchPageModule), - }, - { - path: 'index-area', - loadChildren: () => import('@features/tag/pages/index-area/index-area.page.module').then(m => m.CoreTagIndexAreaPageModule), - }, - { - path: '', - redirectTo: 'search', - pathMatch: 'full', - }, -]; +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; + +function buildRoutes(injector: Injector): Routes { + return [ + { + path: 'index', + loadChildren: () => import('@features/tag/pages/index/index.page.module').then(m => m.CoreTagIndexPageModule), + }, + { + path: 'search', + loadChildren: () => import('@features/tag//pages/search/search.page.module').then(m => m.CoreTagSearchPageModule), + }, + { + path: 'index-area', + loadChildren: () => + import('@features/tag/pages/index-area/index-area.page.module').then(m => m.CoreTagIndexAreaPageModule), + }, + ...buildTabMainRoutes(injector, { + redirectTo: 'search', + pathMatch: 'full', + }), + ]; +} @NgModule({ - imports: [RouterModule.forChild(routes)], exports: [RouterModule], + providers: [ + { + provide: ROUTES, + multi: true, + deps: [Injector], + useFactory: buildRoutes, + }, + ], }) export class CoreTagLazyModule { } diff --git a/src/core/features/user/classes/base-profilefield-component.ts b/src/core/features/user/classes/base-profilefield-component.ts new file mode 100644 index 000000000..88f390862 --- /dev/null +++ b/src/core/features/user/classes/base-profilefield-component.ts @@ -0,0 +1,100 @@ +// (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 } from '@angular/core'; +import { FormGroup, Validators, FormControl } from '@angular/forms'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; + +/** + * Base class for components to render a user profile field. + */ +@Component({ + template: '', +}) +export abstract class CoreUserProfileFieldBaseComponent implements OnInit { + + @Input() field?: AuthEmailSignupProfileField | CoreUserProfileField; // The profile field to be rendered. + @Input() edit = false; // True if editing the field. Defaults to false. + @Input() disabled = false; // True if disabled. Defaults to false. + @Input() form?: FormGroup; // Form where to add the form control. + @Input() contextLevel?: string; // The context level. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() courseId?: number; // The course the field belongs to (if any). + + control?: FormControl; + modelName = ''; + value?: string; + required?: boolean; + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.field) { + return; + } + + if (!this.edit && 'value' in this.field) { + this.initForNonEdit(this.field); + + return; + } + + if (this.edit && 'required' in this.field) { + this.initForEdit(this.field); + + return; + } + + } + + /** + * Init the data when the field is meant to be displayed without editing. + * + * @param field Field to render. + */ + protected initForNonEdit(field: CoreUserProfileField): void { + this.value = field.value; + } + + /** + * Init the data when the field is meant to be displayed for editing. + * + * @param field Field to render. + */ + protected initForEdit(field: AuthEmailSignupProfileField): void { + this.modelName = 'profile_field_' + field.shortname; + this.required = !!field.required; + + this.control = this.createFormControl(field); + this.form?.addControl(this.modelName, this.control); + } + + /** + * Create the Form control. + * + * @return Form control. + */ + protected createFormControl(field: AuthEmailSignupProfileField): FormControl { + const formData = { + value: field.defaultdata, + disabled: this.disabled, + }; + + return new FormControl(formData, this.required && !field.locked ? Validators.required : null); + } + +} diff --git a/src/core/features/user/components/components.module.ts b/src/core/features/user/components/components.module.ts new file mode 100644 index 000000000..00837117a --- /dev/null +++ b/src/core/features/user/components/components.module.ts @@ -0,0 +1,46 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { CoreUserProfileFieldComponent } from './user-profile-field/user-profile-field'; +import { CoreUserTagAreaComponent } from './tag-area/tag-area'; + +@NgModule({ + declarations: [ + CoreUserProfileFieldComponent, + CoreUserTagAreaComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + ], + providers: [ + ], + exports: [ + CoreUserProfileFieldComponent, + CoreUserTagAreaComponent, + ], +}) +export class CoreUserComponentsModule {} diff --git a/src/core/features/user/components/tag-area/core-user-tag-area.html b/src/core/features/user/components/tag-area/core-user-tag-area.html new file mode 100644 index 000000000..e2bd39803 --- /dev/null +++ b/src/core/features/user/components/tag-area/core-user-tag-area.html @@ -0,0 +1,6 @@ + + + +

{{ item.heading }}

+
+
\ No newline at end of file diff --git a/src/core/features/mainmenu/pages/more/more-routing.module.ts b/src/core/features/user/components/tag-area/tag-area.ts similarity index 50% rename from src/core/features/mainmenu/pages/more/more-routing.module.ts rename to src/core/features/user/components/tag-area/tag-area.ts index 11ed18c24..116ecb0dc 100644 --- a/src/core/features/mainmenu/pages/more/more-routing.module.ts +++ b/src/core/features/user/components/tag-area/tag-area.ts @@ -12,22 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core'; +import { Component, Input } from '@angular/core'; -import { ModuleRoutes } from '@/app/app-routing.module'; +import { CoreUserTagFeedElement } from '@features/user/services/handlers/tag-area-handler'; -export const MAIN_MENU_MORE_ROUTES = new InjectionToken('MAIN_MENU_MORE_ROUTES'); +/** + * Component to render the user tag area. + */ +@Component({ + selector: 'core-user-tag-area', + templateUrl: 'core-user-tag-area.html', +}) +export class CoreUserTagAreaComponent { -@NgModule() -export class CoreMainMenuMoreRoutingModule { - - static forChild(routes: Partial): ModuleWithProviders { - return { - ngModule: CoreMainMenuMoreRoutingModule, - providers: [ - { provide: MAIN_MENU_MORE_ROUTES, multi: true, useValue: routes }, - ], - }; - } + @Input() items?: CoreUserTagFeedElement[]; // Area items to render. } diff --git a/src/core/features/user/components/user-profile-field/core-user-profile-field.html b/src/core/features/user/components/user-profile-field/core-user-profile-field.html new file mode 100644 index 000000000..1f3a37007 --- /dev/null +++ b/src/core/features/user/components/user-profile-field/core-user-profile-field.html @@ -0,0 +1 @@ + diff --git a/src/core/features/user/components/user-profile-field/user-profile-field.ts b/src/core/features/user/components/user-profile-field/user-profile-field.ts new file mode 100644 index 000000000..678f7b200 --- /dev/null +++ b/src/core/features/user/components/user-profile-field/user-profile-field.ts @@ -0,0 +1,78 @@ +// (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, Type } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Directive to render user profile field. + */ +@Component({ + selector: 'core-user-profile-field', + templateUrl: 'core-user-profile-field.html', +}) +export class CoreUserProfileFieldComponent implements OnInit { + + @Input() field?: AuthEmailSignupProfileField; // The profile field to be rendered. + @Input() signup = false; // True if editing the field in signup. Defaults to false. + @Input() edit = false; // True if editing the field. Defaults to false. + @Input() form?: FormGroup; // Form where to add the form control. Required if edit=true or signup=true. + @Input() registerAuth?: string; // Register auth method. E.g. 'email'. + @Input() contextLevel?: string; // The context level. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() courseId?: number; // Course ID the field belongs to (if any). It can be used to improve performance with filters. + + componentClass?: Type; // The class of the component to render. + data: CoreUserProfileFieldComponentData = {}; // Data to pass to the component. + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!this.field) { + return; + } + + this.componentClass = await CoreUserProfileFieldDelegate.instance.getComponent(this.field, this.signup); + + this.data.field = this.field; + this.data.edit = CoreUtils.instance.isTrueOrOne(this.edit); + if (this.edit) { + this.data.signup = CoreUtils.instance.isTrueOrOne(this.signup); + this.data.disabled = CoreUtils.instance.isTrueOrOne(this.field.locked); + this.data.form = this.form; + this.data.registerAuth = this.registerAuth; + this.data.contextLevel = this.contextLevel; + this.data.contextInstanceId = this.contextInstanceId; + this.data.courseId = this.courseId; + } + } + +} + +export type CoreUserProfileFieldComponentData = { + field?: AuthEmailSignupProfileField; + edit?: boolean; + signup?: boolean; + disabled?: boolean; + form?: FormGroup; + registerAuth?: string; + contextLevel?: string; + contextInstanceId?: number; + courseId?: number; +}; diff --git a/src/core/features/user/lang/en.json b/src/core/features/user/lang/en.json new file mode 100644 index 000000000..528fe4c5c --- /dev/null +++ b/src/core/features/user/lang/en.json @@ -0,0 +1,27 @@ +{ + "address": "Address", + "city": "City/town", + "contact": "Contact", + "country": "Country", + "description": "Description", + "details": "Details", + "detailsnotavailable": "The details of this user are not available to you.", + "editingteacher": "Teacher", + "email": "Email address", + "emailagain": "Email (again)", + "errorloaduser": "Error loading user.", + "firstname": "First name", + "interests": "Interests", + "lastname": "Surname", + "manager": "Manager", + "newpicture": "New picture", + "noparticipants": "No participants found for this course", + "participants": "Participants", + "phone1": "Phone", + "phone2": "Mobile phone", + "roles": "Roles", + "sendemail": "Email", + "student": "Student", + "teacher": "Non-editing teacher", + "webpage": "Web page" +} \ No newline at end of file diff --git a/src/core/features/user/pages/about/about.html b/src/core/features/user/pages/about/about.html new file mode 100644 index 000000000..c2a48db58 --- /dev/null +++ b/src/core/features/user/pages/about/about.html @@ -0,0 +1,97 @@ + + + + + + {{ title }} + + + + + + + + + + {{ 'core.user.contact' | translate}} + + +

{{ 'core.user.email' | translate }}

+

+ {{ user.email }} +

+
+
+ + +

{{ 'core.user.phone1' | translate}}

+

+ {{ user.phone1 }} +

+
+
+ + +

{{ 'core.user.phone2' | translate}}

+

+ {{ user.phone2 }} +

+
+
+ + +

{{ 'core.user.address' | translate}}

+

+ {{ user.address }} +

+
+
+ + +

{{ 'core.user.city' | translate}}

+

{{ user.city }}

+
+
+ + +

{{ 'core.user.country' | translate}}

+

{{ user.country }}

+
+
+
+ + {{ 'core.userdetails' | translate}} + + +

{{ 'core.user.webpage' | translate}}

+

+ {{ user.url }} +

+
+
+ + +

{{ 'core.user.interests' | translate}}

+

{{ user.interests }}

+
+
+ + +
+ + {{ 'core.user.description' | translate}} + + +

+

+
+
+
+
+ + + +
+
diff --git a/src/core/features/user/pages/about/about.module.ts b/src/core/features/user/pages/about/about.module.ts new file mode 100644 index 000000000..2c2e2c340 --- /dev/null +++ b/src/core/features/user/pages/about/about.module.ts @@ -0,0 +1,49 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreUserComponentsModule } from '@features/user/components/components.module'; + +import { CoreUserAboutPage } from './about.page'; + +const routes: Routes = [ + { + path: '', + component: CoreUserAboutPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreUserComponentsModule, + ], + declarations: [ + CoreUserAboutPage, + ], + exports: [RouterModule], +}) +export class CoreUserAboutPageModule {} diff --git a/src/core/features/user/pages/about/about.page.ts b/src/core/features/user/pages/about/about.page.ts new file mode 100644 index 000000000..bd2dd5c8b --- /dev/null +++ b/src/core/features/user/pages/about/about.page.ts @@ -0,0 +1,115 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { SafeUrl } from '@angular/platform-browser'; +import { IonRefresher } from '@ionic/angular'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEvents } from '@singletons/events'; +import { CoreUser, CoreUserProfile, CoreUserProfileRefreshedData, CoreUserProvider } from '@features/user/services/user'; +import { CoreUserHelper } from '@features/user/services/user-helper'; + +/** + * Page that displays info about a user. + */ +@Component({ + selector: 'page-core-user-about', + templateUrl: 'about.html', +}) +export class CoreUserAboutPage implements OnInit { + + protected userId!: number; + protected siteId: string; + + courseId!: number; + userLoaded = false; + hasContact = false; + hasDetails = false; + user?: CoreUserProfile; + title?: string; + formattedAddress?: string; + encodedAddress?: SafeUrl; + + constructor( + protected route: ActivatedRoute, + ) { + this.siteId = CoreSites.instance.getCurrentSiteId(); + } + + /** + * On init. + * + * @return Promise resolved when done. + */ + async ngOnInit(): Promise { + this.userId = this.route.snapshot.queryParams['userId']; + this.courseId = this.route.snapshot.queryParams['courseId']; + + this.fetchUser().finally(() => { + this.userLoaded = true; + }); + } + + /** + * Fetches the user data. + * + * @return Promise resolved when done. + */ + async fetchUser(): Promise { + try { + const user = await CoreUser.instance.getProfile(this.userId, this.courseId); + + if (user.address) { + this.formattedAddress = CoreUserHelper.instance.formatAddress(user.address, user.city, user.country); + this.encodedAddress = CoreTextUtils.instance.buildAddressURL(user.address); + } + + this.hasContact = !!(user.email || user.phone1 || user.phone2 || user.city || user.country || user.address); + this.hasDetails = !!(user.url || user.interests || (user.customfields && user.customfields.length > 0)); + + this.user = user; + this.title = user.fullname; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.user.errorloaduser', true); + } + } + + /** + * Refresh the user data. + * + * @param event Event. + * @return Promise resolved when done. + */ + async refreshUser(event?: CustomEvent): Promise { + await CoreUtils.instance.ignoreErrors(CoreUser.instance.invalidateUserCache(this.userId)); + + await this.fetchUser(); + + event?.detail.complete(); + + if (this.user) { + CoreEvents.trigger(CoreUserProvider.PROFILE_REFRESHED, { + courseId: this.courseId, + userId: this.userId, + user: this.user, + }, this.siteId); + } + } + +} diff --git a/src/core/features/user/pages/profile/profile.html b/src/core/features/user/pages/profile/profile.html new file mode 100644 index 000000000..d7f1c466b --- /dev/null +++ b/src/core/features/user/pages/profile/profile.html @@ -0,0 +1,90 @@ + + + + + + {{ title }} + + + + + + + + + + + + + + + +

{{handler.title | translate}}

+
+
+
+ + + + + +
+ + + + + + + + + +

{{ handler.title | translate }}

+
+
+ + + + + + {{ handler.title | translate }} + + + + +
+ + + + + +
+
diff --git a/src/core/features/user/pages/profile/profile.module.ts b/src/core/features/user/pages/profile/profile.module.ts new file mode 100644 index 000000000..67f81126b --- /dev/null +++ b/src/core/features/user/pages/profile/profile.module.ts @@ -0,0 +1,47 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +import { CoreUserProfilePage } from './profile.page'; + +const routes: Routes = [ + { + path: '', + component: CoreUserProfilePage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + ], + declarations: [ + CoreUserProfilePage, + ], + exports: [RouterModule], +}) +export class CoreUserProfilePageModule {} diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts new file mode 100644 index 000000000..f4e29d3bb --- /dev/null +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -0,0 +1,288 @@ +// (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, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { IonRefresher, NavController } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreSite } from '@classes/site'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { + CoreUser, + CoreUserProfile, + CoreUserProfilePictureUpdatedData, + CoreUserProfileRefreshedData, + CoreUserProvider, +} from '@features/user/services/user'; +import { CoreUserHelper } from '@features/user/services/user-helper'; +import { CoreUserDelegate, CoreUserDelegateService, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; +import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper'; +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { CoreUtils } from '@services/utils/utils'; + +@Component({ + selector: 'page-core-user-profile', + templateUrl: 'profile.html', + styleUrls: ['profile.scss'], +}) +export class CoreUserProfilePage implements OnInit, OnDestroy { + + protected courseId!: number; + protected userId!: number; + protected site?: CoreSite; + protected obsProfileRefreshed: CoreEventObserver; + protected subscription?: Subscription; + + userLoaded = false; + isLoadingHandlers = false; + user?: CoreUserProfile; + title?: string; + isDeleted = false; + isEnrolled = true; + canChangeProfilePicture = false; + rolesFormatted?: string; + actionHandlers: CoreUserProfileHandlerData[] = []; + newPageHandlers: CoreUserProfileHandlerData[] = []; + communicationHandlers: CoreUserProfileHandlerData[] = []; + + constructor( + protected route: ActivatedRoute, + protected navCtrl: NavController, + ) { + + this.obsProfileRefreshed = CoreEvents.on(CoreUserProvider.PROFILE_REFRESHED, (data) => { + if (!this.user || !data.user) { + return; + } + + this.user.email = data.user.email; + this.user.address = CoreUserHelper.instance.formatAddress('', data.user.city, data.user.country); + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * On init. + */ + async ngOnInit(): Promise { + this.site = CoreSites.instance.getCurrentSite(); + this.userId = this.route.snapshot.queryParams['userId']; + this.courseId = this.route.snapshot.queryParams['courseId']; + + if (!this.site) { + return; + } + + // Allow to change the profile image only in the app profile page. + this.canChangeProfilePicture = + (!this.courseId || this.courseId == this.site.getSiteHomeId()) && + this.userId == this.site.getUserId() && + this.site.canUploadFiles() && + CoreUser.instance.canUpdatePictureInSite(this.site) && + !CoreUser.instance.isUpdatePictureDisabledInSite(this.site); + + try { + await this.fetchUser(); + + try { + await CoreUser.instance.logView(this.userId, this.courseId, this.user!.fullname); + } catch (error) { + this.isDeleted = error?.errorcode === 'userdeleted'; + this.isEnrolled = error?.errorcode !== 'notenrolledprofile'; + } + } finally { + this.userLoaded = true; + } + } + + /** + * Fetches the user and updates the view. + */ + async fetchUser(): Promise { + try { + const user = await CoreUser.instance.getProfile(this.userId, this.courseId); + + user.address = CoreUserHelper.instance.formatAddress('', user.city, user.country); + this.rolesFormatted = 'roles' in user ? CoreUserHelper.instance.formatRoleList(user.roles) : ''; + + this.user = user; + this.title = user.fullname; + + // If there's already a subscription, unsubscribe because we'll get a new one. + this.subscription?.unsubscribe(); + + this.subscription = CoreUserDelegate.instance.getProfileHandlersFor(user, this.courseId).subscribe((handlers) => { + this.actionHandlers = []; + this.newPageHandlers = []; + this.communicationHandlers = []; + handlers.forEach((handler) => { + switch (handler.type) { + case CoreUserDelegateService.TYPE_COMMUNICATION: + this.communicationHandlers.push(handler.data); + break; + case CoreUserDelegateService.TYPE_ACTION: + this.actionHandlers.push(handler.data); + break; + case CoreUserDelegateService.TYPE_NEW_PAGE: + default: + this.newPageHandlers.push(handler.data); + break; + } + }); + + this.isLoadingHandlers = !CoreUserDelegate.instance.areHandlersLoaded(user.id); + }); + + await this.checkUserImageUpdated(); + + } catch (error) { + // Error is null for deleted users, do not show the modal. + CoreDomUtils.instance.showErrorModal(error); + } + } + + /** + * Check if current user image has changed. + * + * @return Promise resolved when done. + */ + protected async checkUserImageUpdated(): Promise { + if (!this.site || !this.site.getInfo() || !this.user) { + return; + } + + if (this.userId != this.site.getUserId() || this.user.profileimageurl == this.site.getInfo()!.userpictureurl) { + // Not current user or hasn't changed. + return; + } + + // The current user image received is different than the one stored in site info. Assume the image was updated. + // Update the site info to get the right avatar in there. + try { + await CoreSites.instance.updateSiteInfo(this.site.getId()); + } catch { + // Cannot update site info. Assume the profile image is the right one. + CoreEvents.trigger(CoreUserProvider.PROFILE_PICTURE_UPDATED, { + userId: this.userId, + picture: this.user.profileimageurl, + }, this.site.getId()); + } + + if (this.user.profileimageurl != this.site.getInfo()!.userpictureurl) { + // The image is still different, this means that the good one is the one in site info. + await this.refreshUser(); + } else { + // Now they're the same, send event to use the right avatar in the rest of the app. + CoreEvents.trigger(CoreUserProvider.PROFILE_PICTURE_UPDATED, { + userId: this.userId, + picture: this.user.profileimageurl, + }, this.site.getId()); + } + } + + /** + * Opens dialog to change profile picture. + */ + async changeProfilePicture(): Promise { + const maxSize = -1; + const title = Translate.instance.instant('core.user.newpicture'); + const mimetypes = CoreMimetypeUtils.instance.getGroupMimeInfo('image', 'mimetypes'); + let modal: CoreIonLoadingElement | undefined; + + try { + const result = await CoreFileUploaderHelper.instance.selectAndUploadFile(maxSize, title, mimetypes); + + modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + const profileImageURL = await CoreUser.instance.changeProfilePicture(result.itemid, this.userId, this.site!.getId()); + + CoreEvents.trigger(CoreUserProvider.PROFILE_PICTURE_UPDATED, { + userId: this.userId, + picture: profileImageURL, + }, this.site!.getId()); + + CoreSites.instance.updateSiteInfo(this.site!.getId()); + + this.refreshUser(); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + modal?.dismiss(); + } + } + + /** + * Refresh the user. + * + * @param event Event. + * @return Promise resolved when done. + */ + async refreshUser(event?: CustomEvent): Promise { + await CoreUtils.instance.ignoreErrors(Promise.all([ + CoreUser.instance.invalidateUserCache(this.userId), + // @todo this.coursesProvider.invalidateUserNavigationOptions(), + // this.coursesProvider.invalidateUserAdministrationOptions() + ])); + + await this.fetchUser(); + + event?.detail.complete(); + + if (this.user) { + CoreEvents.trigger(CoreUserProvider.PROFILE_REFRESHED, { + courseId: this.courseId, + userId: this.userId, + user: this.user, + }, this.site?.getId()); + } + } + + /** + * Open the page with the user details. + */ + openUserDetails(): void { + // @todo: Navigate out of split view if this page is in the right pane. + this.navCtrl.navigateForward(['../about'], { + relativeTo: this.route, + queryParams: { + courseId: this.courseId, + userId: this.userId, + }, + }); + } + + /** + * A handler was clicked. + * + * @param event Click event. + * @param handler Handler that was clicked. + */ + handlerClicked(event: Event, handler: CoreUserProfileHandlerData): void { + // @todo: Pass the right navCtrl if this page is in the right pane of split view. + handler.action(event, this.user!, this.courseId); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + this.obsProfileRefreshed.off(); + } + +} diff --git a/src/core/features/user/pages/profile/profile.scss b/src/core/features/user/pages/profile/profile.scss new file mode 100644 index 000000000..d3bcabffe --- /dev/null +++ b/src/core/features/user/pages/profile/profile.scss @@ -0,0 +1,87 @@ +:host { + + .core-user-profile-maininfo::part(native) { + flex-direction: column; + } + ::ng-deep { + core-user-avatar { + display: block; + --core-avatar-size: var(--core-large-avatar-size); + + img { + margin: 0; + } + + .contact-status { + width: 24px !important; + height: 24px !important; + } + + .core-icon-foreground { + position: absolute; + right: 0; + bottom: 0; + line-height: 30px; + text-align: center; + width: 30px; + height: 30px; + border-radius: 50%; + background-color:var(--background); + + :host-context([dir="rtl"]) & { + left: 0; + right: unset; + } + } + } + } + +} + +:host-context([dir="rtl"]) ::ng-deep core-user-avatar .core-icon-foreground { + left: 0; + right: unset; +} + + // @todo + // .core-user-communication-handlers { + // background: $list-background-color; + // border-bottom: 1px solid $list-border-color; + + // @include darkmode() { + // background: $core-dark-item-bg-color; + // } + + // .core-user-profile-handler { + // background: $list-background-color; + // border: 0; + // color: $core-user-profile-communication-icons-color; + + // @include darkmode() { + // background: $core-dark-item-bg-color; + // } + + // p { + // margin: 0; + // } + + // .icon { + // border-radius: 50%; + // width: 32px; + // height: 32px; + // max-width: 32px; + // font-size: 22px; + // line-height: 32px; + // color: white; + // background-color: $core-user-profile-communication-icons-color; + // margin-bottom: 5px; + // } + // } + // } + + // .core-user-profile-handler { + // ion-spinner { + // @include margin(null, null, null, 0.3em); + // } + // } + diff --git a/src/core/features/user/services/database/user.ts b/src/core/features/user/services/database/user.ts new file mode 100644 index 000000000..7fc2e7a1c --- /dev/null +++ b/src/core/features/user/services/database/user.ts @@ -0,0 +1,90 @@ +// (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 { CoreSiteSchema } from '@services/sites'; +import { CoreUserBasicData } from '../user'; + +/** + * Database variables for CoreUser service. + */ +export const USERS_TABLE_NAME = 'users'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreUserProvider', + version: 1, + canBeCleared: [USERS_TABLE_NAME], + tables: [ + { + name: USERS_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'fullname', + type: 'TEXT', + }, + { + name: 'profileimageurl', + type: 'TEXT', + }, + ], + }, + ], +}; + +/** + * Database variables for CoreUserOffline service. + */ +export const PREFERENCES_TABLE_NAME = 'user_preferences'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreUserOfflineProvider', + version: 1, + tables: [ + { + name: PREFERENCES_TABLE_NAME, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true, + }, + { + name: 'value', + type: 'TEXT', + }, + { + name: 'onlinevalue', + type: 'TEXT', + }, + ], + }, + ], +}; + +/** + * Data stored in DB for users. + */ +export type CoreUserDBRecord = CoreUserBasicData; + +/** + * Structure of offline user preferences. + */ +export type CoreUserPreferenceDBRecord = { + name: string; + value: string; + onlinevalue: string; +}; diff --git a/src/core/features/user/services/handlers/profile-link.ts b/src/core/features/user/services/handlers/profile-link.ts new file mode 100644 index 000000000..ca3f70c6f --- /dev/null +++ b/src/core/features/user/services/handlers/profile-link.ts @@ -0,0 +1,79 @@ +// (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 { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; + +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreNavHelper } from '@services/nav-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to user profiles. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserProfileLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'CoreUserProfileLinkHandler'; + // Match user/view.php and user/profile.php but NOT grade/report/user/. + pattern = /((\/user\/view\.php)|(\/user\/profile\.php)).*([?&]id=\d+)/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @param data Extra data to handle the URL. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + siteIds: string[], + url: string, + params: Params, + courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars + data?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars + ): CoreContentLinksAction[] | Promise { + return [{ + action: (siteId): void => { + const pageParams = { + courseId: params.course, + userId: parseInt(params.id, 10), + }; + + CoreNavHelper.instance.goInSite('/user', pageParams, siteId); + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise { + return url.indexOf('/grade/report/') == -1; + } + +} + +export class CoreUserProfileLinkHandler extends makeSingleton(CoreUserProfileLinkHandlerService) {} diff --git a/src/core/features/user/services/handlers/profile-mail.ts b/src/core/features/user/services/handlers/profile-mail.ts new file mode 100644 index 000000000..a240fc59a --- /dev/null +++ b/src/core/features/user/services/handlers/profile-mail.ts @@ -0,0 +1,78 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '../user-delegate'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreUserProfile } from '../user'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to send a email to a user. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserProfileMailHandlerService implements CoreUserProfileHandler { + + name = 'CoreUserProfileMail'; + priority = 700; + type = CoreUserDelegateService.TYPE_COMMUNICATION; + + /** + * Check if handler is enabled. + * + * @return Always enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Check if handler is enabled for this user in this context. + * + * @param user User to check. + * @param courseId Course ID. + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Promise resolved with true if enabled, resolved with false otherwise. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async isEnabledForUser(user: CoreUserProfile, courseId: number, navOptions?: unknown, admOptions?: unknown): Promise { + return user.id != CoreSites.instance.getCurrentSiteUserId() && !!user.email; + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getDisplayData(user: CoreUserProfile, courseId: number): CoreUserProfileHandlerData { + return { + icon: 'mail', + title: 'core.user.sendemail', + class: 'core-user-profile-mail', + action: (event: Event, user: CoreUserProfile): void => { + event.preventDefault(); + event.stopPropagation(); + + CoreUtils.instance.openInBrowser('mailto:' + user.email); + }, + }; + } + +} + +export class CoreUserProfileMailHandler extends makeSingleton(CoreUserProfileMailHandlerService) {} diff --git a/src/core/features/user/services/handlers/sync-cron.ts b/src/core/features/user/services/handlers/sync-cron.ts new file mode 100644 index 000000000..b188d8d59 --- /dev/null +++ b/src/core/features/user/services/handlers/sync-cron.ts @@ -0,0 +1,53 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { CoreUserSync } from '../user-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserSyncCronHandlerService implements CoreCronHandler { + + name = 'CoreUserSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + execute(siteId?: string, force?: boolean): Promise { + return CoreUserSync.instance.syncPreferences(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } + +} + +export class CoreUserSyncCronHandler extends makeSingleton(CoreUserSyncCronHandlerService) {} diff --git a/src/core/features/user/services/handlers/tag-area-handler.ts b/src/core/features/user/services/handlers/tag-area-handler.ts new file mode 100644 index 000000000..384466574 --- /dev/null +++ b/src/core/features/user/services/handlers/tag-area-handler.ts @@ -0,0 +1,98 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate'; +import { CoreUserTagAreaComponent } from '@features/user/components/tag-area/tag-area'; +import { CoreTagFeedElement } from '@features/tag/services/tag-helper'; +import { CoreUserBasicData } from '../user'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support tags. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserTagAreaHandlerService implements CoreTagAreaHandler { + + name = 'CoreUserTagAreaHandler'; + type = 'core/user'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param content Rendered content. + * @return Area items (or promise resolved with the items). + */ + parseContent(content: string): CoreUserTagFeedElement[] { + const items: CoreUserTagFeedElement[] = []; + const element = CoreDomUtils.instance.convertToElement(content); + + Array.from(element.querySelectorAll('div.user-box')).forEach((userbox: HTMLElement) => { + const avatarLink = userbox.querySelector('a:first-child'); + if (!avatarLink) { + return; + } + + const profileUrl = avatarLink.getAttribute('href') || ''; + const match = profileUrl.match(/.*\/user\/(?:profile|view)\.php\?id=(\d+)/); + if (!match) { + return; + } + + const avatarImg = avatarLink.querySelector('img.userpicture'); + const avatarUrl = avatarImg ? avatarImg.getAttribute('src') : ''; + + items.push({ + avatarUrl, + heading: userbox.innerText, + details: [], + user: { + id: Number(match[1]), + profileimageurl: avatarUrl || '', + fullname: userbox.innerText, + }, + }); + }); + + return items; + } + + /** + * Get the component to use to display items. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return CoreUserTagAreaComponent; + } + +} + +export class CoreUserTagAreaHandler extends makeSingleton(CoreUserTagAreaHandlerService) {} + +export type CoreUserTagFeedElement = CoreTagFeedElement & { + user: CoreUserBasicData; +}; diff --git a/src/core/features/user/services/user-delegate.ts b/src/core/features/user/services/user-delegate.ts new file mode 100644 index 000000000..08b3f799f --- /dev/null +++ b/src/core/features/user/services/user-delegate.ts @@ -0,0 +1,282 @@ +// (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 { Injectable } from '@angular/core'; +import { Subject, BehaviorSubject } from 'rxjs'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEvents } from '@singletons/events'; +import { CoreUserProfile } from './user'; +import { makeSingleton } from '@singletons'; + +/** + * Interface that all user profile handlers must implement. + */ +export interface CoreUserProfileHandler extends CoreDelegateHandler { + /** + * The highest priority is displayed first. + */ + priority: number; + + /** + * A type should be specified among these: + * - TYPE_COMMUNICATION: will be displayed under the user avatar. Should have icon. Spinner not used. + * - TYPE_NEW_PAGE: will be displayed as a list of items. Should have icon. Spinner not used. + * Default value if none is specified. + * - TYPE_ACTION: will be displayed as a button and should not redirect to any state. Spinner use is recommended. + */ + type: string; + + /** + * Whether or not the handler is enabled for a user. + * + * @param user User object. + * @param courseId Course ID where to show. + * @param navOptions Navigation options for the course. + * @param admOptions Admin options for the course. + * @return Whether or not the handler is enabled for a user. + */ + isEnabledForUser(user: CoreUserProfile, courseId: number, navOptions?: unknown, admOptions?: unknown): Promise; + + /** + * Returns the data needed to render the handler. + * + * @param user User object. + * @param courseId Course ID where to show. + * @return Data to be shown. + */ + getDisplayData(user: CoreUserProfile, courseId: number): CoreUserProfileHandlerData; +} + +/** + * Data needed to render a user profile handler. It's returned by the handler. + */ +export interface CoreUserProfileHandlerData { + /** + * Title to display. + */ + title: string; + + /** + * Name of the icon to display. Mandatory for TYPE_COMMUNICATION. + */ + icon?: string; + + /** + * Additional class to add to the HTML. + */ + class?: string; + + /** + * If enabled, element will be hidden. Only for TYPE_NEW_PAGE and TYPE_ACTION. + */ + hidden?: boolean; + + /** + * If enabled will show an spinner. Only for TYPE_ACTION. + */ + spinner?: boolean; + + /** + * Action to do when clicked. + * + * @param event Click event. + * @param user User object. + * @param courseId Course ID being viewed. If not defined, site context. + */ + action(event: Event, user: CoreUserProfile, courseId?: number): void; +} + +/** + * Data returned by the delegate for each handler. + */ +export interface CoreUserProfileHandlerToDisplay { + /** + * Name of the handler. + */ + name?: string; + + /** + * Data to display. + */ + data: CoreUserProfileHandlerData; + + /** + * The highest priority is displayed first. + */ + priority?: number; + + /** + * The type of the handler. See CoreUserProfileHandler. + */ + type: string; +} + +/** + * Service to interact with plugins to be shown in user profile. Provides functions to register a plugin + * and notify an update in the data. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserDelegateService extends CoreDelegate { + + /** + * User profile handler type for communication. + */ + static readonly TYPE_COMMUNICATION = 'communication'; + /** + * User profile handler type for new page. + */ + static readonly TYPE_NEW_PAGE = 'newpage'; + /** + * User profile handler type for actions. + */ + static readonly TYPE_ACTION = 'action'; + + /** + * Update handler information event. + */ + static readonly UPDATE_HANDLER_EVENT = 'CoreUserDelegate_update_handler_event'; + + protected featurePrefix = 'CoreUserDelegate_'; + + // Hold the handlers and the observable to notify them for each user. + protected userHandlers: { + [userId: number]: { + loaded: boolean; // Whether the handlers are loaded. + handlers: CoreUserProfileHandlerToDisplay[]; // List of handlers. + observable: Subject; // Observale to notify the handlers. + }; + } = {}; + + constructor() { + super('CoreUserDelegate', true); + + CoreEvents.on(CoreUserDelegateService.UPDATE_HANDLER_EVENT, (data) => { + if (!data || !data.handler || !this.userHandlers[data.userId]) { + return; + } + + // Search the handler. + const handler = this.userHandlers[data.userId].handlers.find((userHandler) => userHandler.name == data.handler); + + if (!handler) { + return; + } + + // Update the data and notify. + Object.assign(handler.data, data.data); + this.userHandlers[data.userId].observable.next(this.userHandlers[data.userId].handlers); + }); + } + + /** + * Check if handlers are loaded. + * + * @return True if handlers are loaded, false otherwise. + */ + areHandlersLoaded(userId: number): boolean { + return this.userHandlers[userId]?.loaded; + } + + /** + * Clear current user handlers. + * + * @param userId The user to clear. + */ + clearUserHandlers(userId: number): void { + const userData = this.userHandlers[userId]; + + if (userData) { + userData.handlers = []; + userData.observable.next([]); + userData.loaded = false; + } + } + + /** + * Get the profile handlers for a user. + * + * @param user The user object. + * @param courseId The course ID. + * @return Resolved with the handlers. + */ + getProfileHandlersFor(user: CoreUserProfile, courseId: number): Subject { + // Initialize the user handlers if it isn't initialized already. + if (!this.userHandlers[user.id]) { + this.userHandlers[user.id] = { + loaded: false, + handlers: [], + observable: new BehaviorSubject([]), + }; + } + + this.calculateUserHandlers(user, courseId); + + return this.userHandlers[user.id].observable; + } + + /** + * Get the profile handlers for a user. + * + * @param user The user object. + * @param courseId The course ID. + * @return Promise resolved when done. + */ + protected async calculateUserHandlers(user: CoreUserProfile, courseId: number): Promise { + // @todo: Get Course admin/nav options. + let navOptions; + let admOptions; + + const userData = this.userHandlers[user.id]; + userData.handlers = []; + + await CoreUtils.instance.allPromises(Object.keys(this.enabledHandlers).map(async (name) => { + // Checks if the handler is enabled for the user. + const handler = this.handlers[name]; + + try { + const enabled = await handler.isEnabledForUser(user, courseId, navOptions, admOptions); + + if (enabled) { + userData.handlers.push({ + name: name, + data: handler.getDisplayData(user, courseId), + priority: handler.priority || 0, + type: handler.type || CoreUserDelegateService.TYPE_NEW_PAGE, + }); + } + } catch { + // Nothing to do here, it is not enabled for this user. + } + })); + + // Sort them by priority. + userData.handlers.sort((a, b) => (b.priority || 0) - (a.priority || 0)); + userData.loaded = true; + userData.observable.next(userData.handlers); + } + +} + +export class CoreUserDelegate extends makeSingleton(CoreUserDelegateService) {} + +/** + * Data passed to UPDATE_HANDLER_EVENT event. + */ +export type CoreUserUpdateHandlerData = { + handler: string; // Name of the handler. + userId: number; // User affected. + data: Record; // Data to set to the handler. +}; diff --git a/src/core/features/user/services/user-helper.ts b/src/core/features/user/services/user-helper.ts new file mode 100644 index 000000000..3b6bf0a39 --- /dev/null +++ b/src/core/features/user/services/user-helper.ts @@ -0,0 +1,65 @@ +// (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 { Injectable } from '@angular/core'; + +import { makeSingleton, Translate } from '@singletons'; +import { CoreUserRole } from './user'; + +/** + * Service that provides some features regarding users information. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserHelperProvider { + + /** + * Formats a user address, concatenating address, city and country. + * + * @param address Address. + * @param city City. + * @param country Country. + * @return Formatted address. + */ + formatAddress(address?: string, city?: string, country?: string): string { + const separator = Translate.instance.instant('core.listsep'); + let values = [address, city, country]; + + values = values.filter((value) => value && value.length > 0); + + return values.join(separator + ' '); + } + + /** + * Formats a user role list, translating and concatenating them. + * + * @param roles List of user roles. + * @return The formatted roles. + */ + formatRoleList(roles?: CoreUserRole[]): string { + if (!roles || roles.length <= 0) { + return ''; + } + + const separator = Translate.instance.instant('core.listsep'); + + return roles.map((value) => { + const translation = Translate.instance.instant('core.user.' + value.shortname); + + return translation.indexOf('core.user.') < 0 ? translation : value.shortname; + }).join(separator + ' '); + } + +} + +export class CoreUserHelper extends makeSingleton(CoreUserHelperProvider) {} diff --git a/src/core/features/user/services/user-offline.ts b/src/core/features/user/services/user-offline.ts new file mode 100644 index 000000000..ebe3c8e3e --- /dev/null +++ b/src/core/features/user/services/user-offline.ts @@ -0,0 +1,81 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons'; +import { PREFERENCES_TABLE_NAME, CoreUserPreferenceDBRecord } from './database/user'; + +/** + * Service to handle offline user preferences. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserOfflineProvider { + + /** + * Get preferences that were changed offline. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with list of preferences. + */ + async getChangedPreferences(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecordsSelect(PREFERENCES_TABLE_NAME, 'value != onlineValue'); + } + + /** + * Get an offline preference. + * + * @param name Name of the preference. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the preference, rejected if not found. + */ + async getPreference(name: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(PREFERENCES_TABLE_NAME, { name }); + } + + /** + * Set an offline preference. + * + * @param name Name of the preference. + * @param value Value of the preference. + * @param onlineValue Online value of the preference. If undefined, preserve previously stored value. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async setPreference(name: string, value: string, onlineValue?: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + if (typeof onlineValue == 'undefined') { + const preference = await this.getPreference(name, site.id); + + onlineValue = preference.onlinevalue; + } + + const record: CoreUserPreferenceDBRecord = { + name, + value, + onlinevalue: onlineValue, + }; + + await site.getDb().insertRecord(PREFERENCES_TABLE_NAME, record); + } + +} + +export class CoreUserOffline extends makeSingleton(CoreUserOfflineProvider) {} diff --git a/src/core/features/user/services/user-profile-field-delegate.ts b/src/core/features/user/services/user-profile-field-delegate.ts new file mode 100644 index 000000000..833686c4f --- /dev/null +++ b/src/core/features/user/services/user-profile-field-delegate.ts @@ -0,0 +1,209 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreError } from '@classes/errors/error'; +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { makeSingleton } from '@singletons'; +import { CoreUserProfileField } from './user'; + +/** + * Interface that all user profile field handlers must implement. + */ +export interface CoreUserProfileFieldHandler extends CoreDelegateHandler { + /** + * Type of the field the handler supports. E.g. 'checkbox'. + */ + type: string; + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise>; + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + getData?( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise; +} + +export interface CoreUserProfileFieldHandlerData { + /** + * Name of the custom field. + */ + name: string; + + /** + * The type of the custom field + */ + type: string; + + /** + * Value of the custom field. + */ + value: unknown; +} + +/** + * Service to interact with user profile fields. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserProfileFieldDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor() { + super('CoreUserProfileFieldDelegate', true); + } + + /** + * Get the type of a field. + * + * @param field The field to get its type. + * @return The field type. + */ + protected getType(field: AuthEmailSignupProfileField | CoreUserProfileField): string { + return ('type' in field ? field.type : field.datatype) || ''; + } + + /** + * Get the component to use to display an user field. + * + * @param injector Injector. + * @param field User field to get the directive for. + * @param signup True if user is in signup page. + * @return Promise resolved with component to use, undefined if not found. + */ + async getComponent( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + ): Promise | undefined> { + const type = this.getType(field); + + try { + if (signup) { + return await this.executeFunction(type, 'getComponent', []); + } else { + return await this.executeFunctionOnEnabled(type, 'getComponent', []); + } + } catch (error) { + this.logger.error('Error getting component for field', type, error); + } + } + + /** + * Get the data to send for a certain field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form values. + * @return Data to send for the field. + */ + async getDataForField( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const type = this.getType(field); + const handler = this.getHandler(type, !signup); + + if (handler) { + const name = 'profile_field_' + field.shortname; + + if (handler.getData) { + return await handler.getData(field, signup, registerAuth, formValues); + } else if (field.shortname && typeof formValues[name] != 'undefined') { + // Handler doesn't implement the function, but the form has data for the field. + return { + type: type, + name: name, + value: formValues[name], + }; + } + } + + throw new CoreError('User profile field handler not found.'); + } + + /** + * Get the data to send for a list of fields based on the input data. + * + * @param fields User fields to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form values. + * @return Data to send. + */ + async getDataForFields( + fields: (AuthEmailSignupProfileField | CoreUserProfileField)[] | undefined, + signup: boolean = false, + registerAuth: string = '', + formValues: Record, + ): Promise { + if (!fields) { + return []; + } + + const result: CoreUserProfileFieldHandlerData[] = []; + + await Promise.all(fields.map(async (field) => { + try { + const data = await this.getDataForField(field, signup, registerAuth, formValues); + + if (data) { + result.push(data); + } + } catch (error) { + // Ignore errors. + } + })); + + return result; + } + + /** + * Check if any of the profile fields is not supported in the app. + * + * @param fields List of fields. + * @return Whether any of the profile fields is not supported in the app. + */ + hasRequiredUnsupportedField(fields?: AuthEmailSignupProfileField[]): boolean { + if (!fields || !fields.length) { + return false; + } + + return fields.some((field) => field.required && !this.hasHandler(this.getType(field))); + } + +} + +export class CoreUserProfileFieldDelegate extends makeSingleton(CoreUserProfileFieldDelegateService) {} diff --git a/src/core/features/user/services/user-sync.ts b/src/core/features/user/services/user-sync.ts new file mode 100644 index 000000000..644c920ec --- /dev/null +++ b/src/core/features/user/services/user-sync.ts @@ -0,0 +1,107 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { makeSingleton } from '@singletons'; +import { CoreUserOffline } from './user-offline'; +import { CoreUser } from './user'; + +/** + * Service to sync user preferences. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'core_user_autom_synced'; + + constructor() { + super('CoreUserSync'); + } + + /** + * Try to synchronize user preferences in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @return Promise resolved with warnings if sync is successful, rejected if sync fails. + */ + syncPreferences(siteId?: string): Promise { + return this.syncOnSites('all user preferences', this.syncSitePreferences.bind(this), siteId); + } + + /** + * Sync user preferences of a site. + * + * @param siteId Site ID to sync. + * @param Promise resolved with warnings if sync is successful, rejected if sync fails. + */ + async syncSitePreferences(siteId: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const syncId = 'preferences'; + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing, return the promise. + return this.getOngoingSync(syncId, siteId)!; + } + + this.logger.debug('Try to sync user preferences'); + + const syncPromise = this.performSyncSitePreferences(siteId); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Sync user preferences of a site. + * + * @param siteId Site ID to sync. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async performSyncSitePreferences(siteId: string): Promise { + const warnings: string[] = []; + + const preferences = await CoreUserOffline.instance.getChangedPreferences(siteId); + + await CoreUtils.instance.allPromises(preferences.map(async (preference) => { + const onlineValue = await CoreUser.instance.getUserPreferenceOnline(preference.name, siteId); + + if (onlineValue !== null && preference.onlinevalue != onlineValue) { + // Preference was changed on web while the app was offline, do not sync. + return CoreUserOffline.instance.setPreference(preference.name, onlineValue, onlineValue, siteId); + } + + try { + await CoreUser.instance.setUserPreference(preference.name, preference.value, siteId); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + warnings.push(CoreTextUtils.instance.getErrorMessageFromError(error)!); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + })); + + // All done, return the warnings. + return warnings; + } + +} + +export class CoreUserSync extends makeSingleton(CoreUserSyncProvider) {} diff --git a/src/core/features/user/services/user.ts b/src/core/features/user/services/user.ts new file mode 100644 index 000000000..f5055fa26 --- /dev/null +++ b/src/core/features/user/services/user.ts @@ -0,0 +1,1112 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreUserOffline } from './user-offline'; +import { CoreLogger } from '@singletons/logger'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { makeSingleton } from '@singletons'; +import { CoreEvents, CoreEventUserDeletedData } from '@singletons/events'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; +import { CoreError } from '@classes/errors/error'; +import { USERS_TABLE_NAME, CoreUserDBRecord } from './database/user'; + +const ROOT_CACHE_KEY = 'mmUser:'; + +/** + * Service to provide user functionalities. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserProvider { + + static readonly PARTICIPANTS_LIST_LIMIT = 50; // Max of participants to retrieve in each WS call. + static readonly PROFILE_REFRESHED = 'CoreUserProfileRefreshed'; + static readonly PROFILE_PICTURE_UPDATED = 'CoreUserProfilePictureUpdated'; + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreUserProvider'); + + CoreEvents.on(CoreEvents.USER_DELETED, (data) => { + // Search for userid in params. + let userId = 0; + + if (data.params.userid) { + userId = data.params.userid; + } else if (data.params.userids) { + userId = data.params.userids[0]; + } else if (data.params.field === 'id' && data.params.values && data.params.values.length) { + userId = data.params.values[0]; + } else if (data.params.userlist && data.params.userlist.length) { + userId = data.params.userlist[0].userid; + } + + if (userId > 0) { + this.deleteStoredUser(userId, data.siteId); + } + }); + } + + /** + * Check if WS to search participants is available in site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's available. + * @since 3.8 + */ + async canSearchParticipants(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canSearchParticipantsInSite(site); + } + + /** + * Check if WS to search participants is available in site. + * + * @param site Site. If not defined, current site. + * @return Whether it's available. + * @since 3.8 + */ + canSearchParticipantsInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_enrol_search_users'); + } + + /** + * Check if WS to update profile picture is available in site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's available. + * @since 3.2 + */ + async canUpdatePicture(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canUpdatePictureInSite(site); + } + + /** + * Check if WS to search participants is available in site. + * + * @param site Site. If not defined, current site. + * @return Whether it's available. + * @since 3.2 + */ + canUpdatePictureInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_user_update_picture'); + } + + /** + * Change the given user profile picture. + * + * @param draftItemId New picture draft item id. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolve with the new profileimageurl + */ + async changeProfilePicture(draftItemId: number, userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreUserUpdatePictureWSParams = { + draftitemid: draftItemId, + delete: false, + userid: userId, + }; + + const result = await site.write('core_user_update_picture', params); + + if (!result.success) { + return Promise.reject(null); + } + + return result.profileimageurl!; + } + + /** + * Store user basic information in local DB to be retrieved if the WS call fails. + * + * @param userId User ID. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is deleted. + */ + async deleteStoredUser(userId: number, siteId?: string): Promise { + if (isNaN(userId)) { + throw new CoreError('Invalid user ID.'); + } + + const site = await CoreSites.instance.getSite(siteId); + + await Promise.all([ + this.invalidateUserCache(userId, site.getId()), + site.getDb().deleteRecords(USERS_TABLE_NAME, { id: userId }), + ]); + } + + /** + * Get participants for a certain course. + * + * @param courseId ID of the course. + * @param limitFrom Position of the first participant to get. + * @param limitNumber Number of participants to get. + * @param siteId Site Id. If not defined, use current site. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved when the participants are retrieved. + */ + async getParticipants( + courseId: number, + limitFrom: number = 0, + limitNumber: number = CoreUserProvider.PARTICIPANTS_LIST_LIMIT, + siteId?: string, + ignoreCache?: boolean, + ): Promise<{participants: CoreUserParticipant[]; canLoadMore: boolean}> { + + const site = await CoreSites.instance.getSite(siteId); + + this.logger.debug(`Get participants for course '${courseId}' starting at '${limitFrom}'`); + + const params: CoreEnrolGetEnrolledUsersWSParams = { + courseid: courseId, + options: [ + { + name: 'limitfrom', + value: String(limitFrom), + }, + { + name: 'limitnumber', + value: String(limitNumber), + }, + { + name: 'sortby', + value: 'siteorder', + }, + ], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getParticipantsListCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const users = await site.read('core_enrol_get_enrolled_users', params, preSets); + + const canLoadMore = users.length >= limitNumber; + this.storeUsers(users, siteId); + + return { participants: users, canLoadMore: canLoadMore }; + } + + /** + * Get cache key for participant list WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getParticipantsListCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'list:' + courseId; + } + + /** + * Get user profile. The type of profile retrieved depends on the params. + * + * @param userId User's ID. + * @param courseId Course ID to get course profile, undefined or 0 to get site profile. + * @param forceLocal True to retrieve the user data from local DB, false to retrieve it from WS. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolved with the user data. + */ + async getProfile( + userId: number, + courseId?: number, + forceLocal: boolean = false, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (forceLocal) { + try { + return await this.getUserFromLocalDb(userId, siteId); + } catch { + return this.getUserFromWS(userId, courseId, siteId); + } + } + + try { + return await this.getUserFromWS(userId, courseId, siteId); + } catch { + return this.getUserFromLocalDb(userId, siteId); + } + } + + /** + * Get cache key for a user WS call. + * + * @param userId User ID. + * @return Cache key. + */ + protected getUserCacheKey(userId: number): string { + return ROOT_CACHE_KEY + 'data:' + userId; + } + + /** + * Get user basic information from local DB. + * + * @param userId User ID. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is retrieved. + */ + protected async getUserFromLocalDb(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(USERS_TABLE_NAME, { id: userId }); + } + + /** + * Get user profile from WS. + * + * @param userId User ID. + * @param courseId Course ID to get course profile, undefined or 0 to get site profile. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is retrieved. + */ + protected async getUserFromWS( + userId: number, + courseId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserCacheKey(userId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + let users: CoreUserData[] | CoreUserCourseProfile[] | undefined; + + // Determine WS and data to use. + if (courseId && courseId != site.getSiteHomeId()) { + this.logger.debug(`Get participant with ID '${userId}' in course '${courseId}`); + + const params: CoreUserGetCourseUserProfilesWSParams = { + userlist: [ + { + userid: userId, + courseid: courseId, + }, + ], + }; + + users = await site.read('core_user_get_course_user_profiles', params, preSets); + } else { + this.logger.debug(`Get user with ID '${userId}'`); + + const params: CoreUserGetUsersByFieldWSParams = { + field: 'id', + values: [String(userId)], + }; + + users = await site.read('core_user_get_users_by_field', params, preSets); + } + + if (users.length == 0) { + // Shouldn't happen. + throw new CoreError('Cannot retrieve user info.'); + } + + const user: CoreUserData | CoreUserCourseProfile = users[0]; + if (user.country) { + user.country = CoreUtils.instance.getCountryName(user.country); + } + this.storeUser(user.id, user.fullname, user.profileimageurl); + + return user; + } + + /** + * Get a user preference (online or offline). + * + * @param name Name of the preference. + * @param siteId Site Id. If not defined, use current site. + * @return Preference value or null if preference not set. + */ + async getUserPreference(name: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const preference = await CoreUtils.instance.ignoreErrors(CoreUserOffline.instance.getPreference(name, siteId)); + + if (preference && !CoreApp.instance.isOnline()) { + // Offline, return stored value. + return preference.value; + } + + const wsValue = await this.getUserPreferenceOnline(name, siteId); + + if (!wsValue) { + if (preference) { + // Return the local value. + return preference.value; + } + + throw new CoreError('Preference not found'); + } + + if (preference && preference.value != preference.onlinevalue && preference.onlinevalue == wsValue) { + // Sync is pending for this preference, return stored value. + return preference.value; + } + + await CoreUserOffline.instance.setPreference(name, wsValue, wsValue); + + return wsValue; + } + + /** + * Get cache key for a user preference WS call. + * + * @param name Preference name. + * @return Cache key. + */ + protected getUserPreferenceCacheKey(name: string): string { + return ROOT_CACHE_KEY + 'preference:' + name; + } + + /** + * Get a user preference online. + * + * @param name Name of the preference. + * @param siteId Site Id. If not defined, use current site. + * @return Preference value or null if preference not set. + */ + async getUserPreferenceOnline(name: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreUserGetUserPreferencesWSParams = { + name, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserPreferenceCacheKey(params.name!), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + const result = await site.read('core_user_get_user_preferences', params, preSets); + + return result.preferences[0] ? result.preferences[0].value : null; + } + + /** + * Invalidates user WS calls. + * + * @param userId User ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserCache(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserCacheKey(userId)); + } + + /** + * Invalidates participant list for a certain course. + * + * @param courseId Course ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the list is invalidated. + */ + async invalidateParticipantsList(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getParticipantsListCacheKey(courseId)); + } + + /** + * Invalidate user preference. + * + * @param name Name of the preference. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserPreference(name: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserPreferenceCacheKey(name)); + } + + /** + * Check if course participants is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isParticipantsDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isParticipantsDisabledInSite(site); + } + + /** + * Check if course participants is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isParticipantsDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.isFeatureDisabled('CoreCourseOptionsDelegate_CoreUserParticipants'); + } + + /** + * Returns whether or not participants is enabled for a certain course. + * + * @param courseId Course ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + isPluginEnabledForCourse(courseId: number, siteId?: string): Promise { + if (!courseId) { + throw new CoreError('Invalid course ID.'); + } + + // Retrieving one participant will fail if browsing users is disabled by capabilities. + return CoreUtils.instance.promiseWorks(this.getParticipants(courseId, 0, 1, siteId)); + } + + /** + * Check if update profile picture is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return True if disabled, false otherwise. + */ + isUpdatePictureDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.isFeatureDisabled('CoreUserDelegate_picture'); + } + + /** + * Log User Profile View in Moodle. + * + * @param userId User ID. + * @param courseId Course ID. + * @param name Name of the user. + * @return Promise resolved when done. + */ + async logView(userId: number, courseId?: number, name?: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreUserViewUserProfileWSParams = { + userid: userId, + }; + const wsName = 'core_user_view_user_profile'; + + if (courseId) { + params.courseid = courseId; + } + + // @todo this.pushNotificationsProvider.logViewEvent(userId, name, 'user', wsName, {courseid: courseId}); + + return site.write(wsName, params); + } + + /** + * Log Participants list view in Moodle. + * + * @param courseId Course ID. + * @return Promise resolved when done. + */ + async logParticipantsView(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreUserViewUserListWSParams = { + courseid: courseId, + }; + + // @todo this.pushNotificationsProvider.logViewListEvent('user', 'core_user_view_user_list', params); + + return site.write('core_user_view_user_list', params); + } + + /** + * Prefetch user profiles and their images from a certain course. It prevents duplicates. + * + * @param userIds List of user IDs. + * @param courseId Course the users belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when prefetched. + */ + async prefetchProfiles(userIds: number[], courseId?: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + return; + } + + const treated: Record = {}; + + await Promise.all(userIds.map(async (userId) => { + if (userId === null) { + return; + } + + userId = Number(userId); // Make sure it's a number. + + // Prevent repeats and errors. + if (isNaN(userId) || treated[userId] || userId <= 0) { + return; + } + + treated[userId] = true; + + try { + const profile = await this.getProfile(userId, courseId, false, siteId); + + if (profile.profileimageurl) { + await CoreFilepool.instance.addToQueueByUrl(siteId!, profile.profileimageurl); + } + } catch (error) { + this.logger.warn(`Ignore error when prefetching user ${userId}`, error); + } + })); + } + + /** + * Prefetch user avatars. It prevents duplicates. + * + * @param entries List of entries that have the images. + * @param propertyName The name of the property that contains the image. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when prefetched. + */ + async prefetchUserAvatars(entries: Record[], propertyName: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + return; + } + + const treated: Record = {}; + + const promises = entries.map(async (entry) => { + const imageUrl = entry[propertyName]; + + if (!imageUrl || treated[imageUrl]) { + // It doesn't have an image or it has already been treated. + return; + } + + treated[imageUrl] = true; + + try { + await CoreFilepool.instance.addToQueueByUrl(siteId!, imageUrl); + } catch (ex) { + this.logger.warn(`Ignore error when prefetching user avatar ${imageUrl}`, entry, ex); + } + }); + + await Promise.all(promises); + } + + /** + * Search participants in a certain course. + * + * @param courseId ID of the course. + * @param search The string to search. + * @param searchAnywhere Whether to find a match anywhere or only at the beginning. + * @param page Page to get. + * @param limitNumber Number of participants to get. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the participants are retrieved. + * @since 3.8 + */ + async searchParticipants( + courseId: number, + search: string, + searchAnywhere: boolean = true, + page: number = 0, + perPage: number = CoreUserProvider.PARTICIPANTS_LIST_LIMIT, + siteId?: string, + ): Promise<{participants: CoreUserData[]; canLoadMore: boolean}> { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreEnrolSearchUsersWSParams = { + courseid: courseId, + search: search, + searchanywhere: !!searchAnywhere, + page: page, + perpage: perPage, + }; + const preSets: CoreSiteWSPreSets = { + getFromCache: false, // Always try to get updated data. If it fails, it will get it from cache. + }; + + const users = await site.read('core_enrol_search_users', params, preSets); + + const canLoadMore = users.length >= perPage; + this.storeUsers(users, siteId); + + return { participants: users, canLoadMore: canLoadMore }; + } + + /** + * Store user basic information in local DB to be retrieved if the WS call fails. + * + * @param userId User ID. + * @param fullname User full name. + * @param avatar User avatar URL. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is stored. + */ + protected async storeUser(userId: number, fullname: string, avatar?: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const userRecord: CoreUserDBRecord = { + id: userId, + fullname: fullname, + profileimageurl: avatar, + }; + + await site.getDb().insertRecord(USERS_TABLE_NAME, userRecord); + } + + /** + * Store users basic information in local DB. + * + * @param users Users to store. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is stored. + */ + async storeUsers(users: CoreUserBasicData[], siteId?: string): Promise { + + await Promise.all(users.map((user) => { + if (!user.id || isNaN(Number(user.id))) { + return; + } + + return this.storeUser(Number(user.id), user.fullname, user.profileimageurl, siteId); + })); + } + + /** + * Set a user preference (online or offline). + * + * @param name Name of the preference. + * @param value Value of the preference. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved on success. + */ + async setUserPreference(name: string, value: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!CoreApp.instance.isOnline()) { + // Offline, just update the preference. + return CoreUserOffline.instance.setPreference(name, value); + } + + + try { + // Update the preference in the site. + const preferences = [ + { type: name, value }, + ]; + + await this.updateUserPreferences(preferences, undefined, undefined, siteId); + + // Update preference and invalidate data. + await Promise.all([ + CoreUserOffline.instance.setPreference(name, value, value), + CoreUtils.instance.ignoreErrors(this.invalidateUserPreference(name)), + ]); + } catch (error) { + // Preference not saved online. Update the offline one. + await CoreUserOffline.instance.setPreference(name, value); + } + } + + /** + * Update a preference for a user. + * + * @param name Preference name. + * @param value Preference new value. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success. + */ + updateUserPreference(name: string, value: string, userId?: number, siteId?: string): Promise { + const preferences = [ + { + type: name, + value: value, + }, + ]; + + return this.updateUserPreferences(preferences, undefined, userId, siteId); + } + + /** + * Update some preferences for a user. + * + * @param preferences List of preferences. + * @param disableNotifications Whether to disable all notifications. Undefined to not update this value. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success. + */ + async updateUserPreferences( + preferences: { type: string; value: string }[], + disableNotifications?: boolean, + userId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + const params: CoreUserUpdateUserPreferencesWSParams = { + userid: userId, + preferences: preferences, + }; + const preSets: CoreSiteWSPreSets = { + responseExpected: false, + }; + + if (typeof disableNotifications != 'undefined') { + params.emailstop = disableNotifications ? 1 : 0; + } + + await site.write('core_user_update_user_preferences', params, preSets); + } + +} + +export class CoreUser extends makeSingleton(CoreUserProvider) {} + +/** + * Data passed to PROFILE_REFRESHED event. + */ +export type CoreUserProfileRefreshedData = { + courseId: number; // Course the user profile belongs to. + userId: number; // User ID. + user?: CoreUserProfile; // User affected. +}; + +/** + * Data passed to PROFILE_PICTURE_UPDATED event. + */ +export type CoreUserProfilePictureUpdatedData = { + userId: number; // User ID. + picture: string | undefined; // New picture URL. +}; + +/** + * Basic data of a user. + */ +export type CoreUserBasicData = { + id: number; // ID of the user. + fullname: string; // The fullname of the user. + profileimageurl?: string; // User image profile URL - big version. +}; + +/** + * User preference. + */ +export type CoreUserPreference = { + name: string; // The name of the preference. + value: string; // The value of the preferenc. +}; + +/** + * User custom profile field. + */ +export type CoreUserProfileField = { + type: string; // The type of the custom field - text field, checkbox... + value: string; // The value of the custom field. + name: string; // The name of the custom field. + shortname: string; // The shortname of the custom field - to be able to build the field class in the code. +}; + +/** + * User group. + */ +export type CoreUserGroup = { + id: number; // Group id. + name: string; // Group name. + description: string; // Group description. + descriptionformat: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). +}; + +/** + * User role. + */ +export type CoreUserRole = { + roleid: number; // Role id. + name: string; // Role name. + shortname: string; // Role shortname. + sortorder: number; // Role sortorder. +}; + +/** + * Basic data of a course the user is enrolled in. + */ +export type CoreUserEnrolledCourse = { + id: number; // Id of the course. + fullname: string; // Fullname of the course. + shortname: string; // Shortname of the course. +}; + +/** + * Common data returned by user_description function. + */ +export type CoreUserData = { + id: number; // ID of the user. + username?: string; // The username. + firstname?: string; // The first name(s) of the user. + lastname?: string; // The family name of the user. + fullname: string; // The fullname of the user. + email?: string; // An email address - allow email as root@localhost. + address?: string; // Postal address. + phone1?: string; // Phone 1. + phone2?: string; // Phone 2. + icq?: string; // Icq number. + skype?: string; // Skype id. + yahoo?: string; // Yahoo id. + aim?: string; // Aim id. + msn?: string; // Msn number. + department?: string; // Department. + institution?: string; // Institution. + idnumber?: string; // An arbitrary ID code number perhaps from the institution. + interests?: string; // User interests (separated by commas). + firstaccess?: number; // First access to the site (0 if never). + lastaccess?: number; // Last access to the site (0 if never). + auth?: string; // Auth plugins include manual, ldap, etc. + suspended?: boolean; // Suspend user account, either false to enable user login or true to disable it. + confirmed?: boolean; // Active user: 1 if confirmed, 0 otherwise. + lang?: string; // Language code such as "en", must exist on server. + calendartype?: string; // Calendar type such as "gregorian", must exist on server. + theme?: string; // Theme name such as "standard", must exist on server. + timezone?: string; // Timezone code such as Australia/Perth, or 99 for default. + mailformat?: number; // Mail format code is 0 for plain text, 1 for HTML etc. + description?: string; // User profile description. + descriptionformat?: number; // Int format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + city?: string; // Home city of the user. + url?: string; // URL of the user. + country?: string; // Home country code of the user, such as AU or CZ. + profileimageurlsmall: string; // User image profile URL - small version. + profileimageurl: string; // User image profile URL - big version. + customfields?: CoreUserProfileField[]; // User custom fields (also known as user profile fields). + preferences?: CoreUserPreference[]; // Users preferences. +}; + +/** + * Data returned by user_summary_exporter. + */ +export type CoreUserSummary = { + id: number; // Id. + email: string; // Email. + idnumber: string; // Idnumber. + phone1: string; // Phone1. + phone2: string; // Phone2. + department: string; // Department. + institution: string; // Institution. + fullname: string; // Fullname. + identity: string; // Identity. + profileurl: string; // Profileurl. + profileimageurl: string; // Profileimageurl. + profileimageurlsmall: string; // Profileimageurlsmall. +}; + +/** + * User data returned by core_enrol_get_enrolled_users WS. + */ +export type CoreUserParticipant = CoreUserBasicData & { + username?: string; // Username policy is defined in Moodle security config. + firstname?: string; // The first name(s) of the user. + lastname?: string; // The family name of the user. + email?: string; // An email address - allow email as root@localhost. + address?: string; // Postal address. + phone1?: string; // Phone 1. + phone2?: string; // Phone 2. + icq?: string; // Icq number. + skype?: string; // Skype id. + yahoo?: string; // Yahoo id. + aim?: string; // Aim id. + msn?: string; // Msn number. + department?: string; // Department. + institution?: string; // Institution. + idnumber?: string; // An arbitrary ID code number perhaps from the institution. + interests?: string; // User interests (separated by commas). + firstaccess?: number; // First access to the site (0 if never). + lastaccess?: number; // Last access to the site (0 if never). + lastcourseaccess?: number; // Last access to the course (0 if never). + description?: string; // User profile description. + descriptionformat?: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + city?: string; // Home city of the user. + url?: string; // URL of the user. + country?: string; // Home country code of the user, such as AU or CZ. + profileimageurlsmall?: string; // User image profile URL - small version. + customfields?: CoreUserProfileField[]; // User custom fields (also known as user profil fields). + groups?: CoreUserGroup[]; // User groups. + roles?: CoreUserRole[]; // User roles. + preferences?: CoreUserPreference[]; // User preferences. + enrolledcourses?: CoreUserEnrolledCourse[]; // Courses where the user is enrolled. +}; + +/** + * User data returned by core_user_get_course_user_profiles WS. + */ +export type CoreUserCourseProfile = CoreUserData & { + groups?: CoreUserGroup[]; // User groups. + roles?: CoreUserRole[]; // User roles. + enrolledcourses?: CoreUserEnrolledCourse[]; // Courses where the user is enrolled. +}; + +/** + * User data returned by getProfile. + */ +export type CoreUserProfile = (CoreUserBasicData & Partial) | CoreUserCourseProfile; + +/** + * Params of core_user_update_picture WS. + */ +type CoreUserUpdatePictureWSParams = { + draftitemid: number; // Id of the user draft file to use as image. + delete?: boolean; // If we should delete the user picture. + userid?: number; // Id of the user, 0 for current user. +}; + +/** + * Data returned by core_user_update_picture WS. + */ +type CoreUserUpdatePictureWSResponse = { + success: boolean; // True if the image was updated, false otherwise. + profileimageurl?: string; // New profile user image url. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_enrol_get_enrolled_users WS. + */ +type CoreEnrolGetEnrolledUsersWSParams = { + courseid: number; // Course id. + options?: { + name: string; // Option name. + value: string; // Option value. + }[]; +}; + +/** + * Data returned by core_enrol_get_enrolled_users WS. + */ +type CoreEnrolGetEnrolledUsersWSResponse = CoreUserParticipant[]; + +/** + * Params of core_user_get_course_user_profiles WS. + */ +type CoreUserGetCourseUserProfilesWSParams = { + userlist: { + userid: number; // Userid. + courseid: number; // Courseid. + }[]; +}; + +/** + * Data returned by core_user_get_course_user_profiles WS. + */ +type CoreUserGetCourseUserProfilesWSResponse = CoreUserCourseProfile[]; + +/** + * Params of core_user_get_users_by_field WS. + */ +type CoreUserGetUsersByFieldWSParams = { + field: string; // The search field can be 'id' or 'idnumber' or 'username' or 'email'. + values: string[]; +}; +/** + * Data returned by core_user_get_users_by_field WS. + */ +type CoreUserGetUsersByFieldWSResponse = CoreUserData[]; + +/** + * Params of core_user_get_user_preferences WS. + */ +type CoreUserGetUserPreferencesWSParams = { + name?: string; // Preference name, empty for all. + userid?: number; // Id of the user, default to current user. +}; + +/** + * Data returned by core_user_get_user_preferences WS. + */ +type CoreUserGetUserPreferencesWSResponse = { + preferences: { // User custom fields (also known as user profile fields). + name: string; // The name of the preference. + value: string; // The value of the preference. + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_user_view_user_list WS. + */ +type CoreUserViewUserListWSParams = { + courseid: number; // Id of the course, 0 for site. +}; + +/** + * Params of core_user_view_user_profile WS. + */ +type CoreUserViewUserProfileWSParams = { + userid: number; // Id of the user, 0 for current user. + courseid?: number; // Id of the course, default site course. +}; + +/** + * Params of core_user_update_user_preferences WS. + */ +type CoreUserUpdateUserPreferencesWSParams = { + userid?: number; // Id of the user, default to current user. + emailstop?: number; // Enable or disable notifications for this user. + preferences?: { // User preferences. + type: string; // The name of the preference. + value?: string; // The value of the preference, do not set this field if you want to remove (unset) the current value. + }[]; +}; + +/** + * Params of core_enrol_search_users WS. + */ +type CoreEnrolSearchUsersWSParams = { + courseid: number; // Course id. + search: string; // Query. + searchanywhere: boolean; // Find a match anywhere, or only at the beginning. + page: number; // Page number. + perpage: number; // Number per page. +}; + +/** + * Data returned by core_enrol_search_users WS. + */ +type CoreEnrolSearchUsersWSResponse = CoreUserData[]; + diff --git a/src/core/features/user/user-lazy.module.ts b/src/core/features/user/user-lazy.module.ts new file mode 100644 index 000000000..b7f04d730 --- /dev/null +++ b/src/core/features/user/user-lazy.module.ts @@ -0,0 +1,37 @@ +// (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 { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +const routes: Routes = [ + { + path: '', + redirectTo: 'profile', + pathMatch: 'full', + }, + { + path: 'profile', + loadChildren: () => import('./pages/profile/profile.module').then( m => m.CoreUserProfilePageModule), + }, + { + path: 'about', + loadChildren: () => import('./pages/about/about.module').then( m => m.CoreUserAboutPageModule), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], +}) +export class CoreUserLazyModule {} diff --git a/src/core/features/user/user.module.ts b/src/core/features/user/user.module.ts new file mode 100644 index 000000000..669042ec1 --- /dev/null +++ b/src/core/features/user/user.module.ts @@ -0,0 +1,65 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; + +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/user'; +import { CoreUserComponentsModule } from './components/components.module'; +import { CoreUserDelegate } from './services/user-delegate'; +import { CoreUserProfileMailHandler } from './services/handlers/profile-mail'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreUserProfileLinkHandler } from './services/handlers/profile-link'; +import { CoreCronDelegate } from '@services/cron'; +import { CoreUserSyncCronHandler } from './services/handlers/sync-cron'; +import { CoreUserTagAreaHandler } from './services/handlers/tag-area-handler'; +import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; + +const routes: Routes = [ + { + path: 'user', + loadChildren: () => import('@features/user/user-lazy.module').then(m => m.CoreUserLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild({ siblings: routes }), + CoreUserComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ + SITE_SCHEMA, + OFFLINE_SITE_SCHEMA, + ], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreUserDelegate.instance.registerHandler(CoreUserProfileMailHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(CoreUserProfileLinkHandler.instance); + CoreCronDelegate.instance.register(CoreUserSyncCronHandler.instance); + CoreTagAreaDelegate.instance.registerHandler(CoreUserTagAreaHandler.instance); + }, + }, + ], +}) +export class CoreUserModule {} diff --git a/src/core/services/app.ts b/src/core/services/app.ts index 945b9dc4b..66fbc8e8d 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -13,19 +13,19 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Params, Router } from '@angular/router'; +import { Params } from '@angular/router'; import { Connection } from '@ionic-native/network/ngx'; import { CoreDB } from '@services/db'; import { CoreEvents } from '@singletons/events'; import { CoreUtils, PromiseDefer } from '@services/utils/utils'; -import { CoreUrlUtils } from '@services/utils/url'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { makeSingleton, Keyboard, Network, StatusBar, Platform, Device } from '@singletons'; import { CoreLogger } from '@singletons/logger'; import { CoreColors } from '@singletons/colors'; import { DBNAME, SCHEMA_VERSIONS_TABLE_NAME, SCHEMA_VERSIONS_TABLE_SCHEMA, SchemaVersionsDBEntry } from '@services/database/app'; +import { CoreNavHelper } from './nav-helper'; /** * Object responsible of managing schema versions. @@ -58,15 +58,13 @@ export class CoreAppProvider { protected keyboardOpening = false; protected keyboardClosing = false; protected backActions: {callback: () => boolean; priority: number}[] = []; - protected mainMenuId = 0; - protected mainMenuOpen?: number; protected forceOffline = false; // Variables for DB. protected schemaVersionsManager: Promise; protected resolveSchemaVersionsManager!: (schemaVersionsManager: SchemaVersionsManager) => void; - constructor(protected router: Router) { + constructor() { this.schemaVersionsManager = new Promise(resolve => this.resolveSchemaVersionsManager = resolve); this.db = CoreDB.instance.getDB(DBNAME); this.logger = CoreLogger.getInstance('CoreAppProvider'); @@ -167,15 +165,6 @@ export class CoreAppProvider { schemaVersionsManager.set(schema.name, schema.version); } - /** - * Get current page route without params. - * - * @return Current page route. - */ - getCurrentPage(): string { - return CoreUrlUtils.instance.removeUrlParams(this.router.url); - } - /** * Get the application global database. * @@ -189,9 +178,10 @@ export class CoreAppProvider { * Get an ID for a main menu. * * @return Main menu ID. + * @deprecated since 3.9.5. Use CoreNavHelperService.getMainMenuId instead. */ getMainMenuId(): number { - return this.mainMenuId++; + return CoreNavHelper.instance.getMainMenuId(); } /** @@ -231,7 +221,7 @@ export class CoreAppProvider { * Checks if the app is running in a 64 bits desktop environment (not browser). * * @return false. - * @deprecated Desktop support has been removed. + * @deprecated since 3.9.5 Desktop support has been removed. */ is64Bits(): boolean { return false; @@ -250,7 +240,7 @@ export class CoreAppProvider { * Checks if the app is running in a desktop environment (not browser). * * @return false. - * @deprecated Desktop support has been removed. + * @deprecated since 3.9.5 Desktop support has been removed. */ isDesktop(): boolean { return false; @@ -296,7 +286,7 @@ export class CoreAppProvider { * Check if the app is running in a Linux environment. * * @return false. - * @deprecated Desktop support has been removed. + * @deprecated since 3.9.5 Desktop support has been removed. */ isLinux(): boolean { return false; @@ -306,7 +296,7 @@ export class CoreAppProvider { * Check if the app is running in a Mac OS environment. * * @return false. - * @deprecated Desktop support has been removed. + * @deprecated since 3.9.5 Desktop support has been removed. */ isMac(): boolean { return false; @@ -316,9 +306,10 @@ export class CoreAppProvider { * Check if the main menu is open. * * @return Whether the main menu is open. + * @deprecated since 3.9.5. Use CoreNavHelperService.isMainMenuOpen instead. */ isMainMenuOpen(): boolean { - return typeof this.mainMenuOpen != 'undefined'; + return CoreNavHelper.instance.isMainMenuOpen(); } /** @@ -389,7 +380,7 @@ export class CoreAppProvider { * Check if the app is running in a Windows environment. * * @return false. - * @deprecated Desktop support has been removed. + * @deprecated since 3.9.5 Desktop support has been removed. */ isWindows(): boolean { return false; @@ -459,14 +450,10 @@ export class CoreAppProvider { * * @param id Main menu ID. * @param open Whether it's open or not. + * @deprecated since 3.9.5. Use CoreNavHelperService.setMainMenuOpen instead. */ setMainMenuOpen(id: number, open: boolean): void { - if (open) { - this.mainMenuOpen = id; - CoreEvents.trigger(CoreEvents.MAIN_MENU_OPEN); - } else if (this.mainMenuOpen == id) { - delete this.mainMenuOpen; - } + CoreNavHelper.instance.setMainMenuOpen(id, open); } /** diff --git a/src/core/services/local-notifications.ts b/src/core/services/local-notifications.ts index 0dac0d8af..7a60f3ded 100644 --- a/src/core/services/local-notifications.ts +++ b/src/core/services/local-notifications.ts @@ -87,6 +87,10 @@ export class CoreLocalNotificationsProvider { protected async init(): Promise { await Platform.instance.ready(); + if (!this.isAvailable()) { + return; + } + // Listen to events. this.triggerSubscription = LocalNotifications.instance.on('trigger').subscribe((notification: ILocalNotification) => { this.trigger(notification); diff --git a/src/core/services/nav-helper.ts b/src/core/services/nav-helper.ts new file mode 100644 index 000000000..2a98bd871 --- /dev/null +++ b/src/core/services/nav-helper.ts @@ -0,0 +1,363 @@ +// (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 { Injectable } from '@angular/core'; +import { Params, Router } from '@angular/router'; +import { CoreMainMenu } from '@features/mainmenu/services/mainmenu'; +import { NavController } from '@ionic/angular'; +import { NavigationOptions } from '@ionic/angular/providers/nav-controller'; + +import { makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { CoreConstants } from '../constants'; +import { CoreSites } from './sites'; +import { CoreDomUtils } from './utils/dom'; +import { CoreTextUtils } from './utils/text'; +import { CoreUrlUtils } from './utils/url'; + +/** + * Provider to provide some helper functions regarding navigation. + */ +@Injectable({ providedIn: 'root' }) +export class CoreNavHelperService { + + static readonly OPEN_COURSE = 'open_course'; + + protected pageToLoad?: {page: string; params?: Params; time: number}; // Page to load once main menu is opened. + protected mainMenuOpen?: number; + protected mainMenuId = 0; + + constructor( + protected router: Router, + protected navCtrl: NavController, + ) { + CoreEvents.on(CoreEvents.MAIN_MENU_OPEN, () => { + /* If there is any page pending to be opened, do it now. Don't open pages stored more than 5 seconds ago, probably + the function to open the page was called when it shouldn't. */ + if (this.pageToLoad && Date.now() - this.pageToLoad.time < 5000) { + this.loadPageInMainMenu(this.pageToLoad.page, this.pageToLoad.params); + delete this.pageToLoad; + } + }); + } + + /** + * Get current page route without params. + * + * @return Current page route. + */ + getCurrentPage(): string { + return CoreUrlUtils.instance.removeUrlParams(this.router.url); + } + + /** + * Open a new page in the current main menu tab. + * + * @param page Page to open. + * @param pageParams Params to send to the page. + * @return Promise resolved when done. + */ + async goInCurrentMainMenuTab(page: string, pageParams: Params): Promise { + const currentPage = this.getCurrentPage(); + + const routeMatch = currentPage.match(/^\/main\/([^/]+)/); + if (!routeMatch || !routeMatch[0]) { + // Not in a tab. Stop. + return; + } + + let path = ''; + if (routeMatch[1] && page.match(new RegExp(`^/${routeMatch[1]}(/|$)`))) { + path = CoreTextUtils.instance.concatenatePaths('/main', page); + } else { + path = CoreTextUtils.instance.concatenatePaths(routeMatch[0], page); + } + + await this.navCtrl.navigateForward(path, { + queryParams: pageParams, + }); + } + + /** + * Goes to a certain page in a certain site. If the site is current site it will perform a regular navigation, + * otherwise it will load the other site and open the page in main menu. + * + * @param pageName Name of the page to go. + * @param pageParams Params to send to the page. + * @param siteId Site ID. If not defined, current site. + * @param checkMenu If true, check if the root page of a main menu tab. Only the page name will be checked. + * @return Promise resolved when done. + */ + async goInSite(pageName: string, pageParams: Params, siteId?: string, checkMenu?: boolean): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // @todo: When this function was in ContentLinksHelper, this code was inside NgZone. Check if it's needed. + + if (!CoreSites.instance.isLoggedIn() || siteId != CoreSites.instance.getCurrentSiteId()) { + await this.openInSiteMainMenu(pageName, pageParams, siteId); + + return; + } + + if (checkMenu) { + let isInMenu = false; + // Check if the page is in the main menu. + try { + isInMenu = await CoreMainMenu.instance.isCurrentMainMenuHandler(pageName); + } catch { + isInMenu = false; + } + + if (isInMenu) { + // Just select the tab. @todo test. + CoreNavHelper.instance.loadPageInMainMenu(pageName, pageParams); + + return; + } + } + + await this.goInCurrentMainMenuTab(pageName, pageParams); + } + + /** + * Get an ID for a main menu. + * + * @return Main menu ID. + */ + getMainMenuId(): number { + return this.mainMenuId++; + } + + /** + * Open a page that doesn't belong to any site. + * + * @param page Page to open. + * @param params Params of the page. + * @return Promise resolved when done. + */ + async goToNoSitePage(page: string, params?: Params): Promise { + const currentPage = this.getCurrentPage(); + + if (currentPage == page) { + // Already at page, nothing to do. + return; + } + + if (page == '/login/sites') { + // Just open the page as root. + await this.navCtrl.navigateRoot(page, { queryParams: params }); + + return; + } + + if (page == '/login/credentials' && currentPage == '/login/site') { + // Just open the new page to keep the navigation history. + await this.navCtrl.navigateForward(page, { queryParams: params }); + + return; + } + + // Check if there is any site stored. + const hasSites = await CoreSites.instance.hasSites(); + + if (!hasSites) { + // There are sites stored, open sites page first to be able to go back. + await this.navCtrl.navigateRoot('/login/sites'); + + await this.navCtrl.navigateForward(page, { queryParams: params }); + + return; + } + + if (page != '/login/site') { + // Open the new site page to be able to go back. + await this.navCtrl.navigateRoot('/login/site'); + + await this.navCtrl.navigateForward(page, { queryParams: params }); + } else { + // Just open the page as root. + await this.navCtrl.navigateRoot(page, { queryParams: params }); + } + } + + /** + * Go to the initial page of a site depending on 'userhomepage' setting. + * + * @param options Options. + * @return Promise resolved when done. + */ + goToSiteInitialPage(options?: CoreNavHelperOpenMainMenuOptions): Promise { + return this.openMainMenu(options); + } + + /** + * Check if the main menu is open. + * + * @return Whether the main menu is open. + */ + isMainMenuOpen(): boolean { + return typeof this.mainMenuOpen != 'undefined'; + } + + /** + * Load a certain page in the main menu. + * + * @param page Route of the page to load. + * @param params Params to pass to the page. + */ + loadPageInMainMenu(page: string, params?: Params): void { + if (!this.isMainMenuOpen()) { + // Main menu not open. Store the page to be loaded later. + this.pageToLoad = { + page: page, + params: params, + time: Date.now(), + }; + + return; + } + + if (page == CoreNavHelperService.OPEN_COURSE) { + // @todo Use the openCourse function. + } else { + CoreEvents.trigger(CoreEvents.LOAD_PAGE_MAIN_MENU, { redirectPage: page, redirectParams: params }); + } + } + + /** + * Load a site and load a certain page in that site. + * + * @param siteId Site to load. + * @param page Name of the page to load. + * @param params Params to pass to the page. + * @return Promise resolved when done. + */ + protected async loadSiteAndPage(siteId: string, page: string, params?: Params): Promise { + if (siteId == CoreConstants.NO_SITE_ID) { + // Page doesn't belong to a site, just load the page. + await this.navCtrl.navigateRoot(page, params); + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const loggedIn = await CoreSites.instance.loadSite(siteId, page, params); + + if (!loggedIn) { + return; + } + + await this.openMainMenu({ + redirectPage: page, + redirectParams: params, + }); + } catch (error) { + // Site doesn't exist. + await this.navCtrl.navigateRoot('/login/sites'); + } finally { + modal.dismiss(); + } + } + + /** + * Open the main menu, loading a certain page. + * + * @param options Options. + * @return Promise resolved when done. + */ + protected async openMainMenu(options?: CoreNavHelperOpenMainMenuOptions): Promise { + + // Due to DeepLinker, we need to remove the path from the URL before going to main menu. + // IonTabs checks the URL to determine which path to load for deep linking, so we clear the URL. + // @todo this.location.replaceState(''); + + if (options?.redirectPage == CoreNavHelperService.OPEN_COURSE) { + // Load the main menu first, and then open the course. + try { + await this.navCtrl.navigateRoot('/'); + } finally { + // @todo: Open course. + } + } else { + // Open the main menu. + const queryParams: Params = Object.assign({}, options); + delete queryParams.navigationOptions; + + await this.navCtrl.navigateRoot('/', { + queryParams, + ...options?.navigationOptions, + }); + } + } + + /** + * Open a new page, setting it as the root page and loading the right site if needed. + * + * @param page Name of the page to load. Special cases: OPEN_COURSE (to open course page). + * @param params Params to pass to the page. + * @param siteId Site to load. If not defined, current site. + * @return Promise resolved when done. + */ + async openInSiteMainMenu(page: string, params?: Params, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!CoreSites.instance.isLoggedIn()) { + if (siteId) { + await this.loadSiteAndPage(siteId, page, params); + } else { + await this.navCtrl.navigateRoot('/login/sites'); + } + + return; + } + + if (siteId && siteId != CoreSites.instance.getCurrentSiteId()) { + // Target page belongs to a different site. Change site. + // @todo: Check site plugins. + await CoreSites.instance.logout(); + + await this.loadSiteAndPage(siteId, page, params); + } else { + // Current page, open it in main menu. + this.loadPageInMainMenu(page, params); + } + } + + /** + * Set a main menu as open or not. + * + * @param id Main menu ID. + * @param open Whether it's open or not. + */ + setMainMenuOpen(id: number, open: boolean): void { + if (open) { + this.mainMenuOpen = id; + CoreEvents.trigger(CoreEvents.MAIN_MENU_OPEN); + } else if (this.mainMenuOpen == id) { + delete this.mainMenuOpen; + } + } + +} + +export class CoreNavHelper extends makeSingleton(CoreNavHelperService) {} + +export type CoreNavHelperOpenMainMenuOptions = { + redirectPage?: string; // Route of the page to open in main menu. If not defined, default tab will be selected. + redirectParams?: Params; // Params to pass to the selected tab if any. + urlToOpen?: string; // URL to open once the main menu is loaded. + navigationOptions?: NavigationOptions; // Navigation options. +}; diff --git a/src/core/services/sync.ts b/src/core/services/sync.ts index 7c3c7ca22..84a22e8ef 100644 --- a/src/core/services/sync.ts +++ b/src/core/services/sync.ts @@ -112,7 +112,7 @@ export class CoreSyncProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved with done. */ - async insertOrUpdateSyncRecord(component: string, id: string, data: CoreSyncRecord, siteId?: string): Promise { + async insertOrUpdateSyncRecord(component: string, id: string, data: Partial, siteId?: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); data.component = component; diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 247e2e209..fa85d4366 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -20,7 +20,7 @@ import { Md5 } from 'ts-md5'; import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; -import { CoreEvents } from '@singletons/events'; +import { CoreEventFormAction, CoreEvents } from '@singletons/events'; import { CoreFile } from '@services/file'; import { CoreWSExternalWarning } from '@services/ws'; import { CoreTextUtils, CoreTextErrorObject } from '@services/utils/text'; @@ -656,6 +656,10 @@ export class CoreDomUtilsProvider { * @return Error message, null if no error should be displayed. */ getErrorMessage(error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean): string | null { + if (typeof error != 'string' && !error) { + return null; + } + let extraInfo = ''; let errorMessage: string | undefined; @@ -1332,21 +1336,21 @@ export class CoreDomUtilsProvider { * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. * @return Promise resolved with the alert modal. */ - showErrorModal( + async showErrorModal( error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean, autocloseTime?: number, ): Promise { if (this.isCanceledError(error)) { // It's a canceled error, don't display an error. - return Promise.resolve(null); + return null; } const message = this.getErrorMessage(error, needsTranslate); if (message === null) { // Message doesn't need to be displayed, stop. - return Promise.resolve(null); + return null; } const alertOptions: AlertOptions = { @@ -1713,7 +1717,7 @@ export class CoreDomUtilsProvider { } CoreEvents.trigger(CoreEvents.FORM_ACTION, { - action: 'cancel', + action: CoreEventFormAction.CANCEL, form: formRef.nativeElement, }, siteId); } @@ -1731,7 +1735,7 @@ export class CoreDomUtilsProvider { } CoreEvents.trigger(CoreEvents.FORM_ACTION, { - action: 'submit', + action: CoreEventFormAction.SUBMIT, form: formRef.nativeElement || formRef, online: !!online, }, siteId); diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index f7033acd0..eae75d94f 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -85,11 +85,11 @@ export class CoreEvents { * @param siteId Site where to trigger the event. Undefined won't check the site. * @return Observer to stop listening. */ - static on(eventName: string, callBack: (value: unknown) => void, siteId?: string): CoreEventObserver { + static on(eventName: string, callBack: (value: T) => void, siteId?: string): CoreEventObserver { // If it's a unique event and has been triggered already, call the callBack. // We don't need to create an observer because the event won't be triggered again. if (this.uniqueEvents[eventName]) { - callBack(this.uniqueEvents[eventName].data); + callBack( this.uniqueEvents[eventName].data); // Return a fake observer to prevent errors. return { @@ -103,10 +103,10 @@ export class CoreEvents { if (typeof this.observables[eventName] == 'undefined') { // No observable for this event, create a new one. - this.observables[eventName] = new Subject(); + this.observables[eventName] = new Subject(); } - const subscription = this.observables[eventName].subscribe((value: {siteId?: string; [key: string]: unknown}) => { + const subscription = this.observables[eventName].subscribe((value: T & {siteId?: string}) => { if (!siteId || value.siteId == siteId) { callBack(value); } @@ -132,8 +132,8 @@ export class CoreEvents { * @param siteId Site where to trigger the event. Undefined won't check the site. * @return Observer to stop listening. */ - static onMultiple(eventNames: string[], callBack: (value: unknown) => void, siteId?: string): CoreEventObserver { - const observers = eventNames.map((name) => this.on(name, callBack, siteId)); + static onMultiple(eventNames: string[], callBack: (value: T) => void, siteId?: string): CoreEventObserver { + const observers = eventNames.map((name) => this.on(name, callBack, siteId)); // Create and return a CoreEventObserver. return { @@ -152,11 +152,11 @@ export class CoreEvents { * @param data Data to pass to the observers. * @param siteId Site where to trigger the event. Undefined means no Site. */ - static trigger(eventName: string, data?: unknown, siteId?: string): void { + static trigger(eventName: string, data?: T, siteId?: string): void { this.logger.debug(`Event '${eventName}' triggered.`); if (this.observables[eventName]) { if (siteId) { - data = Object.assign(data || {}, { siteId }); + Object.assign(data || {}, { siteId }); } this.observables[eventName].next(data); } @@ -169,14 +169,14 @@ export class CoreEvents { * @param data Data to pass to the observers. * @param siteId Site where to trigger the event. Undefined means no Site. */ - static triggerUnique(eventName: string, data: unknown, siteId?: string): void { + static triggerUnique(eventName: string, data: T, siteId?: string): void { if (this.uniqueEvents[eventName]) { this.logger.debug(`Unique event '${eventName}' ignored because it was already triggered.`); } else { this.logger.debug(`Unique event '${eventName}' triggered.`); if (siteId) { - data = Object.assign(data || {}, { siteId }); + Object.assign(data || {}, { siteId }); } // Store the data so it can be passed to observers that register from now on. @@ -241,3 +241,25 @@ export type CoreEventCourseStatusChanged = { courseId: number; // Course Id. status: string; }; + +/** + * Data passed to USER_DELETED event. + */ +export type CoreEventUserDeletedData = CoreEventSiteData & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: any; // Params sent to the WS that failed. +}; + +export enum CoreEventFormAction { + CANCEL = 'cancel', + SUBMIT = 'submit', +} + +/** + * Data passed to FORM_ACTION event. + */ +export type CoreEventFormActionData = CoreEventSiteData & { + action: CoreEventFormAction; // Action performed. + form: HTMLElement; // Form element. + online?: boolean; // Whether the data was sent to server or not. Only when submitting. +}; diff --git a/src/theme/app.scss b/src/theme/app.scss index f48f74a42..106fcab25 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -203,13 +203,14 @@ ion-card.core-danger-card { // Avatar // ------------------------- // Large centered avatar -img.large-avatar { +img.large-avatar, +.large-avatar img { display: block; margin: auto; - width: 90px; - height: 90px; - max-width: 90px; - max-height: 90px; + width: var(--core-large-avatar-size); + height: var(--core-large-avatar-size); + max-width: var(--core-large-avatar-size); + max-height: var(--core-large-avatar-size); margin-bottom: 10px; border-radius : 50%; padding: 4px; @@ -217,6 +218,11 @@ img.large-avatar { background-color: transparent; } +ion-avatar.large-avatar { + width: var(--core-large-avatar-size); + height: var(--core-large-avatar-size); +} + ion-avatar ion-img, ion-avatar img { text-indent: -99999px; background-color: var(--gray-light); @@ -261,3 +267,9 @@ ion-select.core-button-select, z-index: 100; cursor: pointer; } + +.core-anchor, core-format-text a { + color: -webkit-link; + cursor: pointer; + text-decoration: underline; +} diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 29eebdf2c..2c7b87910 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -24,6 +24,7 @@ --purple: var(--custom-purple, #8e24aa); // Accent (never text). --core-color: var(--custom-main-color, var(--orange)); + --core-online-color: #5cb85c; --ion-color-primary: var(--core-color); --ion-color-primary-rgb: 249,128,18; @@ -169,6 +170,10 @@ --core-course-color-8: var(--custom-course-color-9, #fd79a8); --core-course-color-9: var(--custom-course-color-90, #6c5ce7); --core-star-color: var(--custom-star-color, var(--core-color)); + + --core-large-avatar-size: var(--custom-large-avatar-size, 90px); + + --core-avatar-size: var(--custom-avatar-size, 64px); } /*