diff --git a/src/app/app.component.test.ts b/src/app/app.component.test.ts index ca22b6277..a2d4e0dfa 100644 --- a/src/app/app.component.test.ts +++ b/src/app/app.component.test.ts @@ -13,22 +13,11 @@ // limitations under the License. import { AppComponent } from '@/app/app.component'; -import { CoreEvents } from '@singletons/events'; -import { CoreLang, CoreLangProvider } from '@services/lang'; -import { mockSingleton, renderComponent } from '@/testing/utils'; -import { CoreNavigator, CoreNavigatorService } from '@services/navigator'; +import { renderComponent } from '@/testing/utils'; describe('AppComponent', () => { - let langProvider: CoreLangProvider; - let navigator: CoreNavigatorService; - - beforeEach(() => { - navigator = mockSingleton(CoreNavigator, ['navigate']); - langProvider = mockSingleton(CoreLang, ['clearCustomStrings']); - }); - it('should render', async () => { const fixture = await renderComponent(AppComponent); @@ -36,14 +25,4 @@ describe('AppComponent', () => { expect(fixture.nativeElement.querySelector('ion-router-outlet')).toBeTruthy(); }); - it('cleans up on logout', async () => { - const fixture = await renderComponent(AppComponent); - - fixture.componentInstance.ngOnInit(); - CoreEvents.trigger(CoreEvents.LOGOUT); - - expect(langProvider.clearCustomStrings).toHaveBeenCalled(); - expect(navigator.navigate).toHaveBeenCalledWith('/login/sites', { reset: true }); - }); - }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f6176bf38..4b93666b8 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -14,33 +14,20 @@ 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'; -const MOODLE_SITE_URL_PREFIX = 'url-'; -const MOODLE_VERSION_PREFIX = 'version-'; -const MOODLEAPP_VERSION_PREFIX = 'moodleapp-'; - register(); @Component({ @@ -59,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.removeModeClasses([MOODLE_VERSION_PREFIX, MOODLE_SITE_URL_PREFIX]); - - // 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. @@ -117,53 +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) { - // 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.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(); - - // 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); - } - }); - - CoreEvents.on(CoreEvents.SITE_ADDED, (data) => { - 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.onPlatformReady(); - // Quit app with back button. document.addEventListener('ionBackButton', (event: BackButtonEvent) => { // This callback should have the lowest priority in the app. @@ -244,121 +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 }); - } - } - - /** - * 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/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. 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..d7206e0b6 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 }); } /** @@ -124,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. 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); + } + +}