From d26922565185696238b05477332ab1f7d7ad7bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 31 Jan 2024 12:44:31 +0100 Subject: [PATCH 1/3] MOBILE-4266 site: Add site theme class to html tags --- src/app/app.component.test.ts | 77 +++++++++++++++++-- src/app/app.component.ts | 49 ++++++++---- src/core/features/courses/services/courses.ts | 2 +- 3 files changed, 106 insertions(+), 22 deletions(-) diff --git a/src/app/app.component.test.ts b/src/app/app.component.test.ts index ca22b6277..ed9bcc99f 100644 --- a/src/app/app.component.test.ts +++ b/src/app/app.component.test.ts @@ -16,17 +16,21 @@ import { AppComponent } from '@/app/app.component'; import { CoreEvents } from '@singletons/events'; import { CoreLang, CoreLangProvider } from '@services/lang'; -import { mockSingleton, renderComponent } from '@/testing/utils'; +import { mock, mockSingleton, renderComponent } from '@/testing/utils'; import { CoreNavigator, CoreNavigatorService } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { Http } from '@singletons'; +import { of } from 'rxjs'; +import { CoreSite } from '@classes/sites/site'; +import { CoreUtils } from '@services/utils/utils'; describe('AppComponent', () => { let langProvider: CoreLangProvider; - let navigator: CoreNavigatorService; - beforeEach(() => { - navigator = mockSingleton(CoreNavigator, ['navigate']); - langProvider = mockSingleton(CoreLang, ['clearCustomStrings']); + langProvider = mockSingleton(CoreLang, mock({ getCurrentLanguage: async () => 'en' , clearCustomStrings: () => null })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSingleton(Http, { get: () => of(null as any) }); }); it('should render', async () => { @@ -38,6 +42,7 @@ describe('AppComponent', () => { it('cleans up on logout', async () => { const fixture = await renderComponent(AppComponent); + const navigator: CoreNavigatorService = mockSingleton(CoreNavigator, ['navigate']); fixture.componentInstance.ngOnInit(); CoreEvents.trigger(CoreEvents.LOGOUT); @@ -46,4 +51,66 @@ describe('AppComponent', () => { expect(navigator.navigate).toHaveBeenCalledWith('/login/sites', { reset: true }); }); + it('adds ionic platform and theme classes', async () => { + const fixture = await renderComponent(AppComponent); + const siteUrl = 'https://campus.example.edu'; + const themeName = 'mytheme'; + const themeName2 = 'anothertheme'; + + fixture.componentInstance.ngOnInit(); + + expect(document.documentElement.classList.contains('ionic7')).toBe(true); + + const site = mock(new CoreSite('42', siteUrl, 'token', { info: { + sitename: 'Example Campus', + username: 'admin', + firstname: 'Admin', + lastname: 'User', + fullname: 'Admin User', + lang: 'en', + userid: 1, + siteurl: siteUrl, + userpictureurl: '', + theme: themeName, + functions: [], + } })); + + mockSingleton(CoreSites, { + getSite: () => Promise.resolve(site), + getCurrentSiteId: () => '42', + }); + + CoreEvents.trigger(CoreEvents.LOGIN, {}, '42'); + // Wait the event to be processed. + await CoreUtils.nextTick(); + + expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(true); + expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(false); + + if (site.infos) { + site.infos.theme = themeName2; + } + + CoreEvents.trigger(CoreEvents.SITE_UPDATED, site.infos , '42'); + + // Wait the event to be processed. + await CoreUtils.nextTick(); + + expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(true); + expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(false); + + CoreEvents.trigger(CoreEvents.LOGOUT); + + expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(false); + expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(false); + + CoreEvents.trigger(CoreEvents.SITE_ADDED, site.infos , '42'); + + // Wait the event to be processed. + await CoreUtils.nextTick(); + + expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(true); + expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(false); + }); + }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f6176bf38..3670da649 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -36,10 +36,12 @@ import { CoreUrl } from '@singletons/url'; import { CoreLogger } from '@singletons/logger'; import { CorePromisedValue } from '@classes/promised-value'; import { register } from 'swiper/element/bundle'; +import { CoreSiteInfo, CoreSiteInfoResponse } from '@classes/sites/unauthenticated-site'; const MOODLE_SITE_URL_PREFIX = 'url-'; const MOODLE_VERSION_PREFIX = 'version-'; const MOODLEAPP_VERSION_PREFIX = 'moodleapp-'; +const MOODLE_SITE_THEME_PREFIX = 'theme-site-'; register(); @@ -68,7 +70,7 @@ export class AppComponent implements OnInit, AfterViewInit { CoreLang.clearCustomStrings(); // Remove version classes from body. - this.removeModeClasses([MOODLE_VERSION_PREFIX, MOODLE_SITE_URL_PREFIX]); + this.removeSiteClasses(); // Go to sites page when user is logged out. await CoreNavigator.navigate('/login/sites', { reset: true }); @@ -122,11 +124,7 @@ export class AppComponent implements OnInit, AfterViewInit { const site = await CoreSites.getSite(data.siteId); const info = site.getInfo(); if (info) { - // Add version classes to body. - this.removeModeClasses([MOODLE_VERSION_PREFIX, MOODLE_SITE_URL_PREFIX]); - - this.addVersionClass(MOODLE_VERSION_PREFIX, CoreSites.getReleaseNumber(info.release || '')); - this.addSiteUrlClass(info.siteurl); + this.addSiteClasses(info); } } @@ -142,11 +140,7 @@ export class AppComponent implements OnInit, AfterViewInit { if (data.siteId === CoreSites.getCurrentSiteId()) { this.loadCustomStrings(); - // Add version classes to body. - this.removeModeClasses([MOODLE_VERSION_PREFIX, MOODLE_SITE_URL_PREFIX]); - - this.addVersionClass(MOODLE_VERSION_PREFIX, CoreSites.getReleaseNumber(data.release || '')); - this.addSiteUrlClass(data.siteurl); + this.addSiteClasses(data); } }); @@ -154,11 +148,7 @@ export class AppComponent implements OnInit, AfterViewInit { if (data.siteId === CoreSites.getCurrentSiteId()) { this.loadCustomStrings(); - // Add version classes to body. - this.removeModeClasses([MOODLE_VERSION_PREFIX, MOODLE_SITE_URL_PREFIX]); - - this.addVersionClass(MOODLE_VERSION_PREFIX, CoreSites.getReleaseNumber(data.release || '')); - this.addSiteUrlClass(data.siteurl); + this.addSiteClasses(data); } }); @@ -320,6 +310,33 @@ export class AppComponent implements OnInit, AfterViewInit { } } + /** + * Convenience function to add site classes to html. + * + * @param siteInfo Site Info. + */ + protected addSiteClasses(siteInfo: CoreSiteInfo | CoreSiteInfoResponse): void { + // Add version classes to html tag. + this.removeSiteClasses(); + + this.addVersionClass(MOODLE_VERSION_PREFIX, CoreSites.getReleaseNumber(siteInfo.release || '')); + this.addSiteUrlClass(siteInfo.siteurl); + + if (siteInfo.theme) { + CoreDomUtils.toggleModeClass(MOODLE_SITE_THEME_PREFIX + siteInfo.theme, true); + } + } + + /** + * Convenience function to remove all site mode classes form html. + */ + protected removeSiteClasses(): void { + // Remove version classes from html tag. + this.removeModeClasses( + [MOODLE_VERSION_PREFIX, MOODLE_SITE_URL_PREFIX, MOODLE_SITE_THEME_PREFIX], + ); + } + /** * Converts the provided URL into a CSS class that be used within the page. * This is primarily used to add the siteurl to the body tag as a CSS class. diff --git a/src/core/features/courses/services/courses.ts b/src/core/features/courses/services/courses.ts index f1c01ac63..89f45d9f0 100644 --- a/src/core/features/courses/services/courses.ts +++ b/src/core/features/courses/services/courses.ts @@ -1402,7 +1402,7 @@ export type CoreCourseSearchedData = CoreCourseBasicSearchedData & { enablecompletion?: number; // Completion enabled? 1: yes 0: no. completionnotify?: number; // 1: yes 0: no. lang?: string; // Forced course language. - theme?: string; // Fame of the forced theme. + theme?: string; // Name of the forced theme. marker?: number; // Current course marker. legacyfiles?: number; // If legacy files are enabled. calendartype?: string; // Calendar type. From 6f4e3f747920c30c909703a283e377ae32272b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 31 Jan 2024 11:20:26 +0100 Subject: [PATCH 2/3] MOBILE-4266 app: Sort some app initializers --- src/app/app.component.test.ts | 90 +--------- src/app/app.component.ts | 235 +------------------------- src/core/initializers/app.ts | 22 +++ src/core/services/lang.ts | 17 +- src/core/services/network.ts | 37 +++- src/core/services/sites.ts | 55 +++++- src/core/services/tests/sites.test.ts | 108 ++++++++++++ src/core/singletons/html-classes.ts | 163 ++++++++++++++++++ 8 files changed, 399 insertions(+), 328 deletions(-) create mode 100644 src/core/initializers/app.ts create mode 100644 src/core/services/tests/sites.test.ts create mode 100644 src/core/singletons/html-classes.ts diff --git a/src/app/app.component.test.ts b/src/app/app.component.test.ts index ed9bcc99f..a2d4e0dfa 100644 --- a/src/app/app.component.test.ts +++ b/src/app/app.component.test.ts @@ -13,26 +13,11 @@ // limitations under the License. import { AppComponent } from '@/app/app.component'; -import { CoreEvents } from '@singletons/events'; -import { CoreLang, CoreLangProvider } from '@services/lang'; -import { mock, mockSingleton, renderComponent } from '@/testing/utils'; -import { CoreNavigator, CoreNavigatorService } from '@services/navigator'; -import { CoreSites } from '@services/sites'; -import { Http } from '@singletons'; -import { of } from 'rxjs'; -import { CoreSite } from '@classes/sites/site'; -import { CoreUtils } from '@services/utils/utils'; +import { renderComponent } from '@/testing/utils'; describe('AppComponent', () => { - let langProvider: CoreLangProvider; - beforeEach(() => { - langProvider = mockSingleton(CoreLang, mock({ getCurrentLanguage: async () => 'en' , clearCustomStrings: () => null })); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockSingleton(Http, { get: () => of(null as any) }); - }); - it('should render', async () => { const fixture = await renderComponent(AppComponent); @@ -40,77 +25,4 @@ describe('AppComponent', () => { expect(fixture.nativeElement.querySelector('ion-router-outlet')).toBeTruthy(); }); - it('cleans up on logout', async () => { - const fixture = await renderComponent(AppComponent); - const navigator: CoreNavigatorService = mockSingleton(CoreNavigator, ['navigate']); - - fixture.componentInstance.ngOnInit(); - CoreEvents.trigger(CoreEvents.LOGOUT); - - expect(langProvider.clearCustomStrings).toHaveBeenCalled(); - expect(navigator.navigate).toHaveBeenCalledWith('/login/sites', { reset: true }); - }); - - it('adds ionic platform and theme classes', async () => { - const fixture = await renderComponent(AppComponent); - const siteUrl = 'https://campus.example.edu'; - const themeName = 'mytheme'; - const themeName2 = 'anothertheme'; - - fixture.componentInstance.ngOnInit(); - - expect(document.documentElement.classList.contains('ionic7')).toBe(true); - - const site = mock(new CoreSite('42', siteUrl, 'token', { info: { - sitename: 'Example Campus', - username: 'admin', - firstname: 'Admin', - lastname: 'User', - fullname: 'Admin User', - lang: 'en', - userid: 1, - siteurl: siteUrl, - userpictureurl: '', - theme: themeName, - functions: [], - } })); - - mockSingleton(CoreSites, { - getSite: () => Promise.resolve(site), - getCurrentSiteId: () => '42', - }); - - CoreEvents.trigger(CoreEvents.LOGIN, {}, '42'); - // Wait the event to be processed. - await CoreUtils.nextTick(); - - expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(true); - expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(false); - - if (site.infos) { - site.infos.theme = themeName2; - } - - CoreEvents.trigger(CoreEvents.SITE_UPDATED, site.infos , '42'); - - // Wait the event to be processed. - await CoreUtils.nextTick(); - - expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(true); - expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(false); - - CoreEvents.trigger(CoreEvents.LOGOUT); - - expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(false); - expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(false); - - CoreEvents.trigger(CoreEvents.SITE_ADDED, site.infos , '42'); - - // Wait the event to be processed. - await CoreUtils.nextTick(); - - expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(true); - expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(false); - }); - }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 3670da649..4b93666b8 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -14,34 +14,19 @@ import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'; import { IonRouterOutlet } from '@ionic/angular'; -import { BackButtonEvent, ScrollDetail } from '@ionic/core'; +import { BackButtonEvent } from '@ionic/core'; -import { CoreLang } from '@services/lang'; import { CoreLoginHelper } from '@features/login/services/login-helper'; -import { CoreEvents } from '@singletons/events'; -import { NgZone, SplashScreen } from '@singletons'; -import { CoreNetwork } from '@services/network'; +import { SplashScreen } from '@singletons'; import { CoreApp } from '@services/app'; -import { CoreSites } from '@services/sites'; import { CoreNavigator } from '@services/navigator'; import { CoreSubscriptions } from '@singletons/subscriptions'; import { CoreWindow } from '@singletons/window'; import { CoreUtils } from '@services/utils/utils'; -import { CoreConstants } from '@/core/constants'; -import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; -import { CoreDomUtils } from '@services/utils/dom'; -import { CoreDom } from '@singletons/dom'; import { CorePlatform } from '@services/platform'; -import { CoreUrl } from '@singletons/url'; import { CoreLogger } from '@singletons/logger'; import { CorePromisedValue } from '@classes/promised-value'; import { register } from 'swiper/element/bundle'; -import { CoreSiteInfo, CoreSiteInfoResponse } from '@classes/sites/unauthenticated-site'; - -const MOODLE_SITE_URL_PREFIX = 'url-'; -const MOODLE_VERSION_PREFIX = 'version-'; -const MOODLEAPP_VERSION_PREFIX = 'moodleapp-'; -const MOODLE_SITE_THEME_PREFIX = 'theme-site-'; register(); @@ -61,43 +46,6 @@ export class AppComponent implements OnInit, AfterViewInit { ngOnInit(): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any const win = window; - CoreDomUtils.toggleModeClass('ionic7', true, { includeLegacy: true }); - CoreDomUtils.toggleModeClass('development', CoreConstants.BUILD.isDevelopment); - this.addVersionClass(MOODLEAPP_VERSION_PREFIX, CoreConstants.CONFIG.versionname.replace('-dev', '')); - - CoreEvents.on(CoreEvents.LOGOUT, async () => { - // Unload lang custom strings. - CoreLang.clearCustomStrings(); - - // Remove version classes from body. - this.removeSiteClasses(); - - // Go to sites page when user is logged out. - await CoreNavigator.navigate('/login/sites', { reset: true }); - - if (CoreSitePlugins.hasSitePluginsLoaded) { - // Temporary fix. Reload the page to unload all plugins. - window.location.reload(); - } - }); - - // Listen to scroll to add style when scroll is not 0. - win.addEventListener('ionScroll', async ({ detail, target }: CustomEvent) => { - if ((target as HTMLElement).tagName != 'ION-CONTENT') { - return; - } - const content = (target as HTMLIonContentElement); - - const page = content.closest('.ion-page'); - if (!page) { - return; - } - - page.querySelector('ion-header')?.classList.toggle('core-header-shadow', detail.scrollTop > 0); - - const scrollElement = await content.getScrollElement(); - content.classList.toggle('core-footer-shadow', !CoreDom.scrollIsBottom(scrollElement)); - }); CorePlatform.resume.subscribe(() => { // Wait a second before setting it to false since in iOS there could be some frozen WS calls. @@ -119,41 +67,6 @@ export class AppComponent implements OnInit, AfterViewInit { CoreWindow.open(url); }; - CoreEvents.on(CoreEvents.LOGIN, async (data) => { - if (data.siteId) { - const site = await CoreSites.getSite(data.siteId); - const info = site.getInfo(); - if (info) { - this.addSiteClasses(info); - } - } - - this.loadCustomStrings(); - }); - - // Site config is checked in login. - CoreEvents.on(CoreEvents.LOGIN_SITE_CHECKED, (data) => { - this.addSiteUrlClass(data.config.httpswwwroot); - }); - - CoreEvents.on(CoreEvents.SITE_UPDATED, async (data) => { - if (data.siteId === CoreSites.getCurrentSiteId()) { - this.loadCustomStrings(); - - this.addSiteClasses(data); - } - }); - - CoreEvents.on(CoreEvents.SITE_ADDED, (data) => { - if (data.siteId === CoreSites.getCurrentSiteId()) { - this.loadCustomStrings(); - - this.addSiteClasses(data); - } - }); - - this.onPlatformReady(); - // Quit app with back button. document.addEventListener('ionBackButton', (event: BackButtonEvent) => { // This callback should have the lowest priority in the app. @@ -234,148 +147,4 @@ export class AppComponent implements OnInit, AfterViewInit { return promise; } - /** - * Async init function on platform ready. - */ - protected async onPlatformReady(): Promise { - await CorePlatform.ready(); - - this.logger.debug('Platform is ready'); - - // Refresh online status when changes. - CoreNetwork.onChange().subscribe(() => { - // Execute the callback in the Angular zone, so change detection doesn't stop working. - NgZone.run(() => { - const isOnline = CoreNetwork.isOnline(); - const hadOfflineMessage = CoreDomUtils.hasModeClass('core-offline'); - - CoreDomUtils.toggleModeClass('core-offline', !isOnline, { includeLegacy: true }); - - if (isOnline && hadOfflineMessage) { - CoreDomUtils.toggleModeClass('core-online', true, { includeLegacy: true }); - - setTimeout(() => { - CoreDomUtils.toggleModeClass('core-online', false, { includeLegacy: true }); - }, 3000); - } else if (!isOnline) { - CoreDomUtils.toggleModeClass('core-online', false, { includeLegacy: true }); - } - }); - }); - - const isOnline = CoreNetwork.isOnline(); - CoreDomUtils.toggleModeClass('core-offline', !isOnline, { includeLegacy: true }); - } - - /** - * Load custom lang strings. This cannot be done inside the lang provider because it causes circular dependencies. - */ - protected loadCustomStrings(): void { - const currentSite = CoreSites.getCurrentSite(); - - if (currentSite) { - CoreLang.loadCustomStringsFromSite(currentSite); - } - } - - /** - * Convenience function to add version to html classes. - * - * @param prefix Prefix to add to the class. - * @param release Current release number of the site. - */ - protected addVersionClass(prefix: string, release: string): void { - const parts = release.split('.', 3); - - parts[1] = parts[1] || '0'; - parts[2] = parts[2] || '0'; - - CoreDomUtils.toggleModeClass(prefix + parts[0], true, { includeLegacy: true }); - CoreDomUtils.toggleModeClass(prefix + parts[0] + '-' + parts[1], true, { includeLegacy: true }); - CoreDomUtils.toggleModeClass(prefix + parts[0] + '-' + parts[1] + '-' + parts[2], true, { includeLegacy: true }); - } - - /** - * Convenience function to remove all mode classes form body. - * - * @param prefixes Prefixes of the class mode to be removed. - */ - protected removeModeClasses(prefixes: string[]): void { - for (const modeClass of CoreDomUtils.getModeClasses()) { - if (!prefixes.some((prefix) => modeClass.startsWith(prefix))) { - continue; - } - - CoreDomUtils.toggleModeClass(modeClass, false, { includeLegacy: true }); - } - } - - /** - * Convenience function to add site classes to html. - * - * @param siteInfo Site Info. - */ - protected addSiteClasses(siteInfo: CoreSiteInfo | CoreSiteInfoResponse): void { - // Add version classes to html tag. - this.removeSiteClasses(); - - this.addVersionClass(MOODLE_VERSION_PREFIX, CoreSites.getReleaseNumber(siteInfo.release || '')); - this.addSiteUrlClass(siteInfo.siteurl); - - if (siteInfo.theme) { - CoreDomUtils.toggleModeClass(MOODLE_SITE_THEME_PREFIX + siteInfo.theme, true); - } - } - - /** - * Convenience function to remove all site mode classes form html. - */ - protected removeSiteClasses(): void { - // Remove version classes from html tag. - this.removeModeClasses( - [MOODLE_VERSION_PREFIX, MOODLE_SITE_URL_PREFIX, MOODLE_SITE_THEME_PREFIX], - ); - } - - /** - * Converts the provided URL into a CSS class that be used within the page. - * This is primarily used to add the siteurl to the body tag as a CSS class. - * Extracted from LMS url_to_class_name function. - * - * @param url Url. - * @returns Class name - */ - protected urlToClassName(url: string): string { - const parsedUrl = CoreUrl.parse(url); - - if (!parsedUrl) { - return ''; - } - - let className = parsedUrl.domain?.replace(/\./g, '-') || ''; - - if (parsedUrl.port) { - className += `--${parsedUrl.port}`; - } - if (parsedUrl.path) { - const leading = new RegExp('^/+'); - const trailing = new RegExp('/+$'); - const path = parsedUrl.path.replace(leading, '').replace(trailing, ''); - if (path) { - className += '--' + path.replace(/\//g, '-') || ''; - } - } - - return className; - } - - /** - * Convenience function to add site url to html classes. - */ - protected addSiteUrlClass(siteUrl: string): void { - const className = this.urlToClassName(siteUrl); - - CoreDomUtils.toggleModeClass(MOODLE_SITE_URL_PREFIX + className, true); - } - } diff --git a/src/core/initializers/app.ts b/src/core/initializers/app.ts new file mode 100644 index 000000000..837caf889 --- /dev/null +++ b/src/core/initializers/app.ts @@ -0,0 +1,22 @@ +// (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 { CoreHTMLClasses } from '@singletons/html-classes'; + +/** + * General App initializer. + */ +export default async function(): Promise { + CoreHTMLClasses.initialize(); +} diff --git a/src/core/services/lang.ts b/src/core/services/lang.ts index 00c7e65f2..cf321edda 100644 --- a/src/core/services/lang.ts +++ b/src/core/services/lang.ts @@ -28,6 +28,7 @@ import { AddonFilterMultilangHandler } from '@addons/filter/multilang/services/h import { AddonFilterMultilang2Handler } from '@addons/filter/multilang2/services/handlers/multilang2'; import { firstValueFrom } from 'rxjs'; import { CoreLogger } from '@singletons/logger'; +import { CoreSites } from './sites'; /* * Service to handle language features, like changing the current language. @@ -380,14 +381,22 @@ export class CoreLangProvider { /** * Loads custom strings obtained from site. * - * @param currentSite Current site object. + * @param currentSite Current site object. If not defined, use current site. */ - loadCustomStringsFromSite(currentSite: CoreSite): void { + loadCustomStringsFromSite(currentSite?: CoreSite): void { + currentSite = currentSite ?? CoreSites.getCurrentSite(); + + if (!currentSite) { + return; + } + const customStrings = currentSite.getStoredConfig('tool_mobile_customlangstrings'); - if (customStrings !== undefined) { - this.loadCustomStrings(customStrings); + if (customStrings === undefined) { + return; } + + this.loadCustomStrings(customStrings); } /** diff --git a/src/core/services/network.ts b/src/core/services/network.ts index bedc9549b..397e79f4d 100644 --- a/src/core/services/network.ts +++ b/src/core/services/network.ts @@ -15,8 +15,9 @@ import { Injectable } from '@angular/core'; import { CorePlatform } from '@services/platform'; import { Network } from '@awesome-cordova-plugins/network/ngx'; -import { makeSingleton } from '@singletons'; +import { NgZone, makeSingleton } from '@singletons'; import { Observable, Subject, merge } from 'rxjs'; +import { CoreDomUtils } from './utils/dom'; export enum CoreNetworkConnection { UNKNOWN = 'unknown', @@ -92,6 +93,40 @@ export class CoreNetworkService extends Network { this.fireObservable(); }, false); } + + this.onPlaformReady(); + } + + /** + * Initialize the service when the platform is ready. + */ + async onPlaformReady(): Promise { + await CorePlatform.ready(); + + // Refresh online status when changes. + CoreNetwork.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.run(() => { + const isOnline = this.isOnline(); + + const hadOfflineMessage = CoreDomUtils.hasModeClass('core-offline'); + + CoreDomUtils.toggleModeClass('core-offline', !isOnline, { includeLegacy: true }); + + if (isOnline && hadOfflineMessage) { + CoreDomUtils.toggleModeClass('core-online', true, { includeLegacy: true }); + + setTimeout(() => { + CoreDomUtils.toggleModeClass('core-online', false, { includeLegacy: true }); + }, 3000); + } else if (!isOnline) { + CoreDomUtils.toggleModeClass('core-online', false, { includeLegacy: true }); + } + }); + }); + + const isOnline = this.isOnline(); + CoreDomUtils.toggleModeClass('core-offline', !isOnline, { includeLegacy: true }); } /** diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index 143edd1e8..d5dd45603 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -43,7 +43,7 @@ import { } from '@services/database/sites'; import { CoreArray } from '../singletons/array'; import { CoreNetworkError } from '@classes/errors/network-error'; -import { CoreRedirectPayload } from './navigator'; +import { CoreNavigator, CoreRedirectPayload } from './navigator'; import { CoreSitesFactory } from './sites-factory'; import { CoreText } from '@singletons/text'; import { CoreLoginHelper } from '@features/login/services/login-helper'; @@ -66,6 +66,7 @@ import { CoreCacheManager } from '@services/cache-manager'; import { CoreSiteInfo, CoreSiteInfoResponse, CoreSitePublicConfigResponse } from '@classes/sites/unauthenticated-site'; import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site'; import { firstValueFrom } from 'rxjs'; +import { CoreHTMLClasses } from '@singletons/html-classes'; export const CORE_SITE_SCHEMAS = new InjectionToken('CORE_SITE_SCHEMAS'); export const CORE_SITE_CURRENT_SITE_ID_CONFIG = 'current_site_id'; @@ -111,6 +112,7 @@ export class CoreSitesProvider { * Initialize. */ initialize(): void { + // Initialize general site events. CoreEvents.on(CoreEvents.SITE_DELETED, async ({ siteId }) => { if (!siteId || !(siteId in this.siteTables)) { return; @@ -125,6 +127,57 @@ export class CoreSitesProvider { delete this.siteTables[siteId]; }); + CoreEvents.on(CoreEvents.LOGOUT, async () => { + // Unload lang custom strings. + CoreLang.clearCustomStrings(); + + // Remove version classes from body. + CoreHTMLClasses.removeSiteClasses(); + + // Go to sites page when user is logged out. + await CoreNavigator.navigate('/login/sites', { reset: true }); + + if (CoreSitePlugins.hasSitePluginsLoaded) { + // Temporary fix. Reload the page to unload all plugins. + window.location.reload(); + } + }); + + CoreEvents.on(CoreEvents.LOGIN, async (data) => { + if (data.siteId) { + const site = await CoreSites.getSite(data.siteId); + const info = site.getInfo(); + if (info) { + CoreHTMLClasses.addSiteClasses(info); + } + } + + CoreLang.loadCustomStringsFromSite(); + }); + + // Site config is checked in login. + CoreEvents.on(CoreEvents.LOGIN_SITE_CHECKED, (data) => { + CoreHTMLClasses.addSiteUrlClass(data.config.httpswwwroot); + }); + + CoreEvents.on(CoreEvents.SITE_UPDATED, async (data) => { + if (data.siteId !== CoreSites.getCurrentSiteId()) { + return; + } + + CoreLang.loadCustomStringsFromSite(); + CoreHTMLClasses.addSiteClasses(data); + }); + + CoreEvents.on(CoreEvents.SITE_ADDED, (data) => { + if (data.siteId !== CoreSites.getCurrentSiteId()) { + return; + } + + CoreLang.loadCustomStringsFromSite(); + CoreHTMLClasses.addSiteClasses(data); + }); + CoreCacheManager.registerInvalidateListener(() => this.invalidateCaches()); } diff --git a/src/core/services/tests/sites.test.ts b/src/core/services/tests/sites.test.ts new file mode 100644 index 000000000..3147be247 --- /dev/null +++ b/src/core/services/tests/sites.test.ts @@ -0,0 +1,108 @@ +// (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 { CoreEvents } from '@singletons/events'; +import { CoreLang, CoreLangProvider } from '@services/lang'; + +import { mock, mockSingleton } from '@/testing/utils'; +import { CoreNavigator, CoreNavigatorService } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { Http } from '@singletons'; +import { of } from 'rxjs'; +import { CoreSite } from '@classes/sites/site'; +import { CoreHTMLClasses } from '@singletons/html-classes'; +import { CoreUtils } from '@services/utils/utils'; + +describe('CoreSitesProvider', () => { + + let langProvider: CoreLangProvider; + beforeEach(() => { + langProvider = mockSingleton(CoreLang, mock({ getCurrentLanguage: async () => 'en' , clearCustomStrings: () => null })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSingleton(Http, { get: () => of(null as any) }); + }); + + it('cleans up on logout', async () => { + const navigator: CoreNavigatorService = mockSingleton(CoreNavigator, ['navigate']); + + CoreSites.initialize(); + CoreEvents.trigger(CoreEvents.LOGOUT); + + expect(langProvider.clearCustomStrings).toHaveBeenCalled(); + expect(navigator.navigate).toHaveBeenCalledWith('/login/sites', { reset: true }); + }); + + it('adds ionic platform and theme classes', async () => { + const siteUrl = 'https://campus.example.edu'; + const themeName = 'mytheme'; + const themeName2 = 'anothertheme'; + + CoreHTMLClasses.initialize(); + CoreSites.initialize(); + + expect(document.documentElement.classList.contains('ionic7')).toBe(true); + + const site = mock(new CoreSite('42', siteUrl, 'token', { info: { + sitename: 'Example Campus', + username: 'admin', + firstname: 'Admin', + lastname: 'User', + fullname: 'Admin User', + lang: 'en', + userid: 1, + siteurl: siteUrl, + userpictureurl: '', + theme: themeName, + functions: [], + } })); + + mockSingleton(CoreSites, { + getSite: () => Promise.resolve(site), + getCurrentSiteId: () => '42', + }); + + CoreEvents.trigger(CoreEvents.LOGIN, {}, '42'); + // Wait the event to be processed. + await CoreUtils.nextTick(); + + expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(true); + expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(false); + + if (site.infos) { + site.infos.theme = themeName2; + } + + CoreEvents.trigger(CoreEvents.SITE_UPDATED, site.infos , '42'); + + // Wait the event to be processed. + await CoreUtils.nextTick(); + + expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(true); + expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(false); + + CoreEvents.trigger(CoreEvents.LOGOUT); + + expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(false); + expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(false); + + CoreEvents.trigger(CoreEvents.SITE_ADDED, site.infos , '42'); + + // Wait the event to be processed. + await CoreUtils.nextTick(); + + expect(document.documentElement.classList.contains('theme-site-'+themeName2)).toBe(true); + expect(document.documentElement.classList.contains('theme-site-'+themeName)).toBe(false); + }); + +}); diff --git a/src/core/singletons/html-classes.ts b/src/core/singletons/html-classes.ts new file mode 100644 index 000000000..ca348cd7c --- /dev/null +++ b/src/core/singletons/html-classes.ts @@ -0,0 +1,163 @@ +// (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 { CoreSiteInfo, CoreSiteInfoResponse } from '@classes/sites/unauthenticated-site'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrl } from './url'; +import { CoreConstants } from '../constants'; +import { ScrollDetail } from '@ionic/angular'; +import { CoreDom } from './dom'; + +const MOODLE_SITE_URL_PREFIX = 'url-'; +const MOODLE_VERSION_PREFIX = 'version-'; +const MOODLEAPP_VERSION_PREFIX = 'moodleapp-'; +const MOODLE_SITE_THEME_PREFIX = 'theme-site-'; + +/** + * Singleton with helper functions to manage HTML classes. + */ +export class CoreHTMLClasses { + + /** + * Initialize HTML classes. + */ + static initialize(): void { + CoreDomUtils.toggleModeClass('ionic7', true); + CoreDomUtils.toggleModeClass('development', CoreConstants.BUILD.isDevelopment); + CoreHTMLClasses.addVersionClass(MOODLEAPP_VERSION_PREFIX, CoreConstants.CONFIG.versionname.replace('-dev', '')); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const win = window; + + // Listen to scroll to add style when scroll is not 0. + win.addEventListener('ionScroll', async ({ detail, target }: CustomEvent) => { + if ((target as HTMLElement).tagName != 'ION-CONTENT') { + return; + } + const content = (target as HTMLIonContentElement); + + const page = content.closest('.ion-page'); + if (!page) { + return; + } + + page.querySelector('ion-header')?.classList.toggle('core-header-shadow', detail.scrollTop > 0); + + const scrollElement = await content.getScrollElement(); + content.classList.toggle('core-footer-shadow', !CoreDom.scrollIsBottom(scrollElement)); + }); + } + + /** + * Convenience function to add version to html classes. + * + * @param prefix Prefix to add to the class. + * @param release Current release number of the site. + */ + static addVersionClass(prefix: string, release: string): void { + const parts = release.split('.', 3); + + parts[1] = parts[1] || '0'; + parts[2] = parts[2] || '0'; + + CoreDomUtils.toggleModeClass(prefix + parts[0], true, { includeLegacy: true }); + CoreDomUtils.toggleModeClass(prefix + parts[0] + '-' + parts[1], true, { includeLegacy: true }); + CoreDomUtils.toggleModeClass(prefix + parts[0] + '-' + parts[1] + '-' + parts[2], true, { includeLegacy: true }); + } + + /** + * Convenience function to remove all mode classes form body. + * + * @param prefixes Prefixes of the class mode to be removed. + */ + protected static removeModeClasses(prefixes: string[]): void { + for (const modeClass of CoreDomUtils.getModeClasses()) { + if (!prefixes.some((prefix) => modeClass.startsWith(prefix))) { + continue; + } + + CoreDomUtils.toggleModeClass(modeClass, false, { includeLegacy: true }); + } + } + + /** + * Convenience function to add site classes to html. + * + * @param siteInfo Site Info. + */ + static addSiteClasses(siteInfo: CoreSiteInfo | CoreSiteInfoResponse): void { + // Add version classes to html tag. + this.removeSiteClasses(); + + this.addVersionClass(MOODLE_VERSION_PREFIX, CoreSites.getReleaseNumber(siteInfo.release || '')); + this.addSiteUrlClass(siteInfo.siteurl); + + if (siteInfo.theme) { + CoreDomUtils.toggleModeClass(MOODLE_SITE_THEME_PREFIX + siteInfo.theme, true); + } + } + + /** + * Convenience function to remove all site mode classes form html. + */ + static removeSiteClasses(): void { + // Remove version classes from html tag. + this.removeModeClasses( + [MOODLE_VERSION_PREFIX, MOODLE_SITE_URL_PREFIX, MOODLE_SITE_THEME_PREFIX], + ); + } + + /** + * Converts the provided URL into a CSS class that be used within the page. + * This is primarily used to add the siteurl to the body tag as a CSS class. + * Extracted from LMS url_to_class_name function. + * + * @param url Url. + * @returns Class name + */ + protected static urlToClassName(url: string): string { + const parsedUrl = CoreUrl.parse(url); + + if (!parsedUrl) { + return ''; + } + + let className = parsedUrl.domain?.replace(/\./g, '-') || ''; + + if (parsedUrl.port) { + className += `--${parsedUrl.port}`; + } + if (parsedUrl.path) { + const leading = new RegExp('^/+'); + const trailing = new RegExp('/+$'); + const path = parsedUrl.path.replace(leading, '').replace(trailing, ''); + if (path) { + className += '--' + path.replace(/\//g, '-') || ''; + } + } + + return className; + } + + /** + * Convenience function to add site url to html classes. + */ + static addSiteUrlClass(siteUrl: string): void { + const className = this.urlToClassName(siteUrl); + + CoreDomUtils.toggleModeClass(MOODLE_SITE_URL_PREFIX + className, true); + } + +} From 8b38b54fb90fa3200fe8a90f1a63ae22adffb564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 31 Jan 2024 12:24:30 +0100 Subject: [PATCH 3/3] MOBILE-4266 app: isOnline always returned true on webapp --- src/core/services/network.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/services/network.ts b/src/core/services/network.ts index 397e79f4d..d7206e0b6 100644 --- a/src/core/services/network.ts +++ b/src/core/services/network.ts @@ -159,8 +159,15 @@ export class CoreNetworkService extends Network { return; } - const type = this.connectionType; + // We cannot use navigator.onLine because it has issues in some devices. + // See https://bugs.chromium.org/p/chromium/issues/detail?id=811122 + if (!CorePlatform.isAndroid()) { + this.online = navigator.onLine; + return; + } + + const type = this.connectionType; let online = type !== null && type !== CoreNetworkConnection.NONE && type !== CoreNetworkConnection.UNKNOWN; // Double check we are not online because we cannot rely 100% in Cordova APIs.