diff --git a/.eslintrc.js b/.eslintrc.js index 672e6da70..28fd5a986 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,6 +15,7 @@ module.exports = { 'prettier/@typescript-eslint', 'plugin:jest/recommended', 'plugin:@angular-eslint/recommended', + 'plugin:promise/recommended', ], parser: '@typescript-eslint/parser', parserOptions: { @@ -22,11 +23,12 @@ module.exports = { sourceType: 'module', }, plugins: [ - 'eslint-plugin-prefer-arrow', - 'eslint-plugin-jsdoc', '@typescript-eslint', 'header', 'jest', + 'jsdoc', + 'prefer-arrow', + 'promise', ], rules: { '@angular-eslint/component-class-suffix': ['error', { suffixes: ['Component', 'Page'] }], @@ -56,7 +58,29 @@ module.exports = { accessibility: 'no-public', }, ], - '@typescript-eslint/indent': 'off', + '@typescript-eslint/explicit-module-boundary-types': [ + 'error', + { + allowArgumentsExplicitlyTypedAsAny: true, + }, + ], + '@typescript-eslint/indent': [ + 'error', + 4, + { + SwitchCase: 1, + ignoredNodes: [ + 'ClassProperty *', + ], + }, + ], + '@typescript-eslint/lines-between-class-members': [ + 'error', + 'always', + { + exceptAfterSingleLine: true, + }, + ], '@typescript-eslint/member-delimiter-style': [ 'error', { @@ -104,18 +128,6 @@ module.exports = { 'always', ], '@typescript-eslint/type-annotation-spacing': 'error', - '@typescript-eslint/typedef': [ - 'error', - { - arrayDestructuring: false, - arrowParameter: false, - memberVariableDeclaration: true, - objectDestructuring: false, - parameter: true, - propertyDeclaration: true, - variableDeclaration: false, - }, - ], '@typescript-eslint/unified-signatures': 'error', 'header/header': [ 2, @@ -137,12 +149,20 @@ module.exports = { ], 1, ], + 'promise/catch-or-return': [ + 'warn', + { + allowFinally: true, + }, + ], 'arrow-body-style': ['error', 'as-needed'], 'array-bracket-spacing': ['error', 'never'], 'comma-dangle': ['error', 'always-multiline'], 'constructor-super': 'error', 'curly': 'error', 'eol-last': 'error', + 'function-call-argument-newline': ['error', 'consistent'], + 'function-paren-newline': ['error', 'multiline-arguments'], 'id-blacklist': [ 'error', 'any', diff --git a/package-lock.json b/package-lock.json index 25072b58c..a336a9a04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7826,6 +7826,12 @@ "integrity": "sha512-C8YMhL+r8RMeMdYAw/rQtE6xNdMulj+zGWud/qIGnlmomiPRaLDGLMeskZ3alN6uMBojmooRimtdrXebLN4svQ==", "dev": true }, + "eslint-plugin-promise": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz", + "integrity": "sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==", + "dev": true + }, "eslint-scope": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", diff --git a/package.json b/package.json index 7c084682c..f92313d7e 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "eslint-plugin-jest": "^24.1.0", "eslint-plugin-jsdoc": "^30.6.3", "eslint-plugin-prefer-arrow": "^1.2.2", + "eslint-plugin-promise": "^4.2.1", "faker": "^5.1.0", "jest": "^26.5.0", "jest-preset-angular": "^8.3.1", diff --git a/src/app/services/utils/iframe.ts b/src/app/services/utils/iframe.ts index c34c35faf..3dae9ab13 100644 --- a/src/app/services/utils/iframe.ts +++ b/src/app/services/utils/iframe.ts @@ -51,25 +51,9 @@ export class CoreIframeUtilsProvider { constructor() { this.logger = CoreLogger.getInstance('CoreUtilsProvider'); - const win = window; - - if (CoreApp.instance.isIOS() && win.WKUserScript) { - Platform.instance.ready().then(() => { - // Inject code to the iframes because we cannot access the online ones. - const wwwPath = CoreFile.instance.getWWWAbsolutePath(); - const linksPath = CoreTextUtils.instance.concatenatePaths(wwwPath, 'assets/js/iframe-treat-links.js'); - const recaptchaPath = CoreTextUtils.instance.concatenatePaths(wwwPath, 'assets/js/iframe-recaptcha.js'); - - win.WKUserScript.addScript({ id: 'CoreIframeUtilsLinksScript', file: linksPath }); - win.WKUserScript.addScript({ - id: 'CoreIframeUtilsRecaptchaScript', - file: recaptchaPath, - injectionTime: WKUserScriptInjectionTime.END, - }); - - // Handle post messages received by iframes. - window.addEventListener('message', this.handleIframeMessage.bind(this)); - }); + if (CoreApp.instance.isIOS() && 'WKUserScript' in window) { + // eslint-disable-next-line promise/catch-or-return + Platform.instance.ready().then(() => this.injectiOSScripts(window)); } } @@ -214,11 +198,15 @@ export class CoreIframeUtilsProvider { * @param contentDocument The document of the element contents. * @param navCtrl NavController to use if a link can be opened in the app. */ - redefineWindowOpen(element: CoreFrameElement, contentWindow: Window, contentDocument: Document, - navCtrl?: NavController): void { + redefineWindowOpen( + element: CoreFrameElement, + contentWindow: Window, + contentDocument: Document, + navCtrl?: NavController, + ): void { if (contentWindow) { // Intercept window.open. - contentWindow.open = (url: string, name: string): Window => { + contentWindow.open = (url: string, name: string) => { this.windowOpen(url, name, element, navCtrl); return null; @@ -387,8 +375,11 @@ export class CoreIframeUtilsProvider { * @param event Click event. * @return Promise resolved when done. */ - protected async linkClicked(link: {href: string; target?: string}, element?: HTMLFrameElement | HTMLObjectElement, - event?: Event): Promise { + protected async linkClicked( + link: {href: string; target?: string}, + element?: HTMLFrameElement | HTMLObjectElement, + event?: Event, + ): Promise { if (event && event.defaultPrevented) { // Event already prevented by some other code. return; @@ -454,6 +445,27 @@ export class CoreIframeUtilsProvider { } } + /** + * Inject code to the iframes because we cannot access the online ones. + * + * @param userScriptWindow Window. + */ + private injectiOSScripts(userScriptWindow: WKUserScriptWindow) { + const wwwPath = CoreFile.instance.getWWWAbsolutePath(); + const linksPath = CoreTextUtils.instance.concatenatePaths(wwwPath, 'assets/js/iframe-treat-links.js'); + const recaptchaPath = CoreTextUtils.instance.concatenatePaths(wwwPath, 'assets/js/iframe-recaptcha.js'); + + userScriptWindow.WKUserScript.addScript({ id: 'CoreIframeUtilsLinksScript', file: linksPath }); + userScriptWindow.WKUserScript.addScript({ + id: 'CoreIframeUtilsRecaptchaScript', + file: recaptchaPath, + injectionTime: WKUserScriptInjectionTime.END, + }); + + // Handle post messages received by iframes. + window.addEventListener('message', this.handleIframeMessage.bind(this)); + } + } export class CoreIframeUtils extends makeSingleton(CoreIframeUtilsProvider) {} diff --git a/src/app/services/utils/text.ts b/src/app/services/utils/text.ts index 638c6236f..30ce8fb26 100644 --- a/src/app/services/utils/text.ts +++ b/src/app/services/utils/text.ts @@ -450,8 +450,17 @@ export class CoreTextUtilsProvider { * @param courseId Course ID the text belongs to. It can be used to improve performance with filters. * @deprecated since 3.8.3. Please use viewText instead. */ - expandText(title: string, text: string, component?: string, componentId?: string | number, files?: CoreWSExternalFile[], - filter?: boolean, contextLevel?: string, instanceId?: number, courseId?: number): void { + expandText( + title: string, + text: string, + component?: string, + componentId?: string | number, + files?: CoreWSExternalFile[], + filter?: boolean, + contextLevel?: string, + instanceId?: number, + courseId?: number, + ): void { return this.viewText(title, text, { component, componentId, diff --git a/src/app/services/utils/utils.ts b/src/app/services/utils/utils.ts index f58db2c05..c61f367b4 100644 --- a/src/app/services/utils/utils.ts +++ b/src/app/services/utils/utils.ts @@ -48,12 +48,8 @@ export class CoreUtilsProvider { constructor(protected zone: NgZone) { this.logger = CoreLogger.getInstance('CoreUtilsProvider'); - Platform.instance.ready().then(() => { - if (window.cordova && window.cordova.InAppBrowser) { - // Override the default window.open with the InAppBrowser one. - window.open = window.cordova.InAppBrowser.open; - } - }); + // eslint-disable-next-line promise/catch-or-return + Platform.instance.ready().then(() => this.overrideWindowOpen()); } /** @@ -76,7 +72,7 @@ export class CoreUtilsProvider { if (!this.isWebServiceError(error)) { // Local error. Add an extra warning. - errorMessage += '

' + Translate.instance.instant('core.errorsomedatanotdownloaded'); + errorMessage += '

' + Translate.instance.instant('core.errorsomedatanotdownloaded'); } return errorMessage; @@ -88,35 +84,25 @@ export class CoreUtilsProvider { * @param promises Promises. * @return Promise resolved if all promises are resolved and rejected if at least 1 promise fails. */ - allPromises(promises: Promise[]): Promise { + async allPromises(promises: Promise[]): Promise { if (!promises || !promises.length) { return Promise.resolve(); } - return new Promise((resolve, reject): void => { - const total = promises.length; - let count = 0; - let hasFailed = false; - let error; + const getPromiseError = async (promise): Promise => { + try { + await promise; + } catch (error) { + return error; + } + }; - promises.forEach((promise) => { - promise.catch((err) => { - hasFailed = true; - error = err; - }).finally(() => { - count++; + const errors = await Promise.all(promises.map(getPromiseError)); + const error = errors.find(error => !!error); - if (count === total) { - // All promises have finished, reject/resolve. - if (hasFailed) { - reject(error); - } else { - resolve(); - } - } - }); - }); - }); + if (error) { + throw error; + } } /** @@ -151,9 +137,13 @@ export class CoreUtilsProvider { * @param undefinedIsNull True if undefined is equal to null. Defaults to true. * @return Whether both items are equal. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types - basicLeftCompare(itemA: any, itemB: any, maxLevels: number = 0, - level: number = 0, undefinedIsNull: boolean = true): boolean { + basicLeftCompare( + itemA: any, // eslint-disable-line @typescript-eslint/no-explicit-any + itemB: any, // eslint-disable-line @typescript-eslint/no-explicit-any + maxLevels: number = 0, + level: number = 0, + undefinedIsNull: boolean = true, + ): boolean { if (typeof itemA == 'function' || typeof itemB == 'function') { return true; // Don't compare functions. } else if (typeof itemA == 'object' && typeof itemB == 'object') { @@ -467,19 +457,24 @@ export class CoreUtilsProvider { * @param ...args All the params sent after checkAll will be passed to isEnabledFn. * @return Promise resolved with the list of enabled sites. */ - filterEnabledSites

(siteIds: string[], isEnabledFn: (siteId, ...args: P) => boolean | Promise, - checkAll?: boolean, ...args: P): Promise { + filterEnabledSites

( + siteIds: string[], + isEnabledFn: (siteId, ...args: P) => boolean | Promise, + checkAll?: boolean, + ...args: P + ): Promise { const promises = []; const enabledSites = []; for (const i in siteIds) { const siteId = siteIds[i]; + const pushIfEnabled = enabled => enabled && enabledSites.push(siteId); if (checkAll || !promises.length) { - promises.push(Promise.resolve(isEnabledFn.apply(isEnabledFn, [siteId].concat(args))).then((enabled) => { - if (enabled) { - enabledSites.push(siteId); - } - })); + promises.push( + Promise + .resolve(isEnabledFn(siteId, ...args)) + .then(pushIfEnabled), + ); } } @@ -527,8 +522,13 @@ export class CoreUtilsProvider { * @param maxDepth Max Depth to convert to tree. Children found will be in the last level of depth. * @return Array with the formatted tree, children will be on each node under children field. */ - formatTree(list: T[], parentFieldName: string = 'parent', idFieldName: string = 'id', rootParentId: number = 0, - maxDepth: number = 5): TreeNode[] { + formatTree( + list: T[], + parentFieldName: string = 'parent', + idFieldName: string = 'id', + rootParentId: number = 0, + maxDepth: number = 5, + ): TreeNode[] { const map = {}; const mapDepth = {}; const tree: TreeNode[] = []; @@ -649,7 +649,7 @@ export class CoreUtilsProvider { if (fallbackLang === defaultLang) { // Same language, just reject. - return Promise.reject('Countries not found.'); + throw new Error('Countries not found.'); } return this.getCountryKeysListForLanguage(fallbackLang); @@ -786,7 +786,7 @@ export class CoreUtilsProvider { * @param value Value to check. * @return Whether the value is false, 0 or "0". */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + // eslint-disable-next-line @typescript-eslint/no-explicit-any isFalseOrZero(value: any): boolean { return typeof value != 'undefined' && (value === false || value === 'false' || parseInt(value, 10) === 0); } @@ -797,7 +797,7 @@ export class CoreUtilsProvider { * @param value Value to check. * @return Whether the value is true, 1 or "1". */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + // eslint-disable-next-line @typescript-eslint/no-explicit-any isTrueOrOne(value: any): boolean { return typeof value != 'undefined' && (value === true || value === 'true' || parseInt(value, 10) === 1); } @@ -808,7 +808,7 @@ export class CoreUtilsProvider { * @param error Error to check. * @return Whether the error was returned by the WebService. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + // eslint-disable-next-line @typescript-eslint/no-explicit-any isWebServiceError(error: any): boolean { return error && (typeof error.warningcode != 'undefined' || (typeof error.errorcode != 'undefined' && error.errorcode != 'invalidtoken' && error.errorcode != 'userdeleted' && error.errorcode != 'upgraderunning' && @@ -827,8 +827,12 @@ export class CoreUtilsProvider { * @param defaultValue Element that will become default option value. Default 0. * @return The now assembled array */ - makeMenuFromList(list: string, defaultLabel?: string, separator: string = ',', - defaultValue?: T): { label: string; value: T | number }[] { + makeMenuFromList( + list: string, + defaultLabel?: string, + separator: string = ',', + defaultValue?: T, + ): { label: string; value: T | number }[] { // Split and format the list. const split = list.split(separator).map((label, index) => ({ label: label.trim(), @@ -863,7 +867,7 @@ export class CoreUtilsProvider { * @param value Value to check. * @return True if not null and not undefined. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + // eslint-disable-next-line @typescript-eslint/no-explicit-any notNullOrUndefined(value: any): boolean { return typeof value != 'undefined' && value !== null; } @@ -1008,36 +1012,32 @@ export class CoreUtilsProvider { * @param url The URL of the file. * @return Promise resolved when opened. */ - openOnlineFile(url: string): Promise { + async openOnlineFile(url: string): Promise { if (CoreApp.instance.isAndroid()) { // In Android we need the mimetype to open it. - return this.getMimeTypeFromUrl(url).catch(() => { - // Error getting mimetype, return undefined. - }).then((mimetype) => { - if (!mimetype) { - // Couldn't retrieve mimetype. Return error. - return Promise.reject(Translate.instance.instant('core.erroropenfilenoextension')); - } + const mimetype = await this.ignoreErrors(this.getMimeTypeFromUrl(url)); - const options = { - action: WebIntent.instance.ACTION_VIEW, - url, - type: mimetype, - }; + if (!mimetype) { + // Couldn't retrieve mimetype. Return error. + throw new Error(Translate.instance.instant('core.erroropenfilenoextension')); + } - return WebIntent.instance.startActivity(options).catch((error) => { - this.logger.error('Error opening online file ' + url + ' with mimetype ' + mimetype); - this.logger.error('Error: ', JSON.stringify(error)); + const options = { + action: WebIntent.instance.ACTION_VIEW, + url, + type: mimetype, + }; - return Promise.reject(Translate.instance.instant('core.erroropenfilenoapp')); - }); + return WebIntent.instance.startActivity(options).catch((error) => { + this.logger.error('Error opening online file ' + url + ' with mimetype ' + mimetype); + this.logger.error('Error: ', JSON.stringify(error)); + + throw new Error(Translate.instance.instant('core.erroropenfilenoapp')); }); } // In the rest of platforms we need to open them in InAppBrowser. this.openInApp(url); - - return Promise.resolve(); } /** @@ -1062,8 +1062,13 @@ export class CoreUtilsProvider { * @param sortByValue True to sort values alphabetically, false otherwise. * @return Array of objects with the name & value of each property. */ - objectToArrayOfObjects(obj: Record, keyName: string, valueName: string, sortByKey?: boolean, - sortByValue?: boolean): Record[] { + objectToArrayOfObjects( + obj: Record, + keyName: string, + valueName: string, + sortByKey?: boolean, + sortByValue?: boolean, + ): Record[] { // Get the entries from an object or primitive value. const getEntries = (elKey: string, value: unknown): Record[] | unknown => { if (typeof value == 'undefined' || value == null) { @@ -1123,8 +1128,12 @@ export class CoreUtilsProvider { * @param keyPrefix Key prefix if neededs to delete it. * @return Object. */ - objectToKeyValueMap(objects: Record[], keyName: string, valueName: string, - keyPrefix?: string): {[name: string]: unknown} { + objectToKeyValueMap( + objects: Record[], + keyName: string, + valueName: string, + keyPrefix?: string, + ): {[name: string]: unknown} { if (!objects) { return; } @@ -1343,13 +1352,25 @@ export class CoreUtilsProvider { */ timeoutPromise(promise: Promise, time: number): Promise { return new Promise((resolve, reject): void => { - const timeout = setTimeout(() => { - reject({ timeout: true }); - }, time); + let timedOut = false; + const resolveBeforeTimeout = () => { + if (timedOut) { + return; + } + resolve(); + }; + const timeout = setTimeout( + () => { + reject({ timeout: true }); + timedOut = true; + }, + time, + ); - promise.then(resolve).catch(reject).finally(() => { - clearTimeout(timeout); - }); + promise + .then(resolveBeforeTimeout) + .catch(reject) + .finally(() => clearTimeout(timeout)); }); } @@ -1362,7 +1383,7 @@ export class CoreUtilsProvider { * @param strict If true, then check the input and return false if it is not a valid number. * @return False if bad format, empty string if empty value or the parsed float if not. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any unformatFloat(localeFloat: any, strict?: boolean): false | '' | number { // Bad format on input type number. if (typeof localeFloat == 'undefined') { @@ -1568,6 +1589,17 @@ export class CoreUtilsProvider { return new Promise(resolve => setTimeout(resolve, milliseconds)); } + /** + * Override native window.open with InAppBrowser if available. + */ + private overrideWindowOpen() { + if (!window.cordova?.InAppBrowser) { + return; + } + + window.open = window.cordova.InAppBrowser.open; + } + } export class CoreUtils extends makeSingleton(CoreUtilsProvider) {}