From 910d557b8724bd4d8d71d94c4f4da2167fa5f3fd Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 31 Mar 2022 17:16:19 +0200 Subject: [PATCH 1/3] MOBILE-4015 core: Support data-app-url attribute --- src/core/directives/aria-button.ts | 18 ++-------- src/core/directives/format-text.ts | 58 ++++++++++++++++++++++++++++++ src/core/directives/link.ts | 19 ++-------- src/core/singletons/dom.ts | 23 ++++++++++++ 4 files changed, 85 insertions(+), 33 deletions(-) diff --git a/src/core/directives/aria-button.ts b/src/core/directives/aria-button.ts index 092c53737..3d0d29593 100644 --- a/src/core/directives/aria-button.ts +++ b/src/core/directives/aria-button.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Directive, ElementRef, OnInit, Output, EventEmitter } from '@angular/core'; +import { CoreDom } from '@singletons/dom'; /** * Directive to emulate click and key actions following aria role button. @@ -36,22 +37,7 @@ export class CoreAriaButtonClickDirective implements OnInit { * Initialize actions. */ ngOnInit(): void { - this.element.addEventListener('click', async (event) => { - this.ariaButtonClick.emit(event); - }); - - this.element.addEventListener('keydown', async (event) => { - if ((event.key == ' ' || event.key == 'Enter')) { - event.preventDefault(); - event.stopPropagation(); - } - }); - - this.element.addEventListener('keyup', async (event) => { - if ((event.key == ' ' || event.key == 'Enter')) { - this.ariaButtonClick.emit(event); - } - }); + CoreDom.onActivate(this.element, (event) => this.ariaButtonClick.emit(event)); } } diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index f134eddd6..7b4d8a7ea 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -452,9 +452,16 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo const svgImages = Array.from(div.querySelectorAll('image')); const promises: Promise[] = []; + this.treatAppUrlElements(div, site); + // Walk through the content to find the links and add our directive to it. // Important: We need to look for links first because in 'img' we add new links without core-link. anchors.forEach((anchor) => { + if (anchor.getAttribute('data-app-url')) { + // Link already treated in data-app-url, ignore it. + return; + } + // Angular 2 doesn't let adding directives dynamically. Create the CoreLinkDirective manually. const linkDir = new CoreLinkDirective(new ElementRef(anchor), this.content); linkDir.capture = this.captureLinks ?? true; @@ -546,6 +553,57 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo await Promise.all(promises); } + /** + * Treat elements with an app-url data attribute. + * + * @param div Div containing the elements. + * @param site Site. + */ + protected treatAppUrlElements(div: HTMLElement, site?: CoreSite): void { + const appUrlElements = Array.from(div.querySelectorAll('*[data-app-url]')); + + appUrlElements.forEach((element) => { + const url = element.getAttribute('data-app-url'); + if (!url) { + return; + } + + if (element.tagName !== 'BUTTON' && element.tagName !== 'A') { + element.setAttribute('tabindex', '0'); + element.setAttribute('role', 'button'); + } + + CoreDom.onActivate(element, async (event) => { + event.preventDefault(); + event.stopPropagation(); + + site = site || CoreSites.getCurrentSite(); + if (!site) { + return; + } + + const confirmMessage = element.getAttribute('data-app-url-confirm'); + const openInApp = element.getAttribute('data-open-in') === 'app'; + + if (confirmMessage) { + try { + await CoreDomUtils.showConfirm(Translate.instant(confirmMessage)); + } catch { + return; + } + } + + if (openInApp) { + site.openInAppWithAutoLoginIfSameSite(url); + } else { + site.openInBrowserWithAutoLoginIfSameSite(url, undefined, { + showBrowserWarning: !confirmMessage, + }); + } + }); + }); + } + /** * Returns the element width in pixels. * diff --git a/src/core/directives/link.ts b/src/core/directives/link.ts index 37043ffc8..5df73e728 100644 --- a/src/core/directives/link.ts +++ b/src/core/directives/link.ts @@ -48,7 +48,7 @@ export class CoreLinkDirective implements OnInit { @Input() autoLogin = 'check'; @Input() showBrowserWarning = true; // Whether to show a warning before opening browser. Defaults to true. - protected element: Element; + protected element: HTMLElement; constructor( element: ElementRef, @@ -68,22 +68,7 @@ export class CoreLinkDirective implements OnInit { this.element.setAttribute('role', 'button'); } - this.element.addEventListener('click', async (event) => { - this.performAction(event); - }); - - this.element.addEventListener('keydown', (event: KeyboardEvent) => { - if ((event.key == ' ' || event.key == 'Enter')) { - event.preventDefault(); - event.stopPropagation(); - } - }); - - this.element.addEventListener('keyup', (event: KeyboardEvent) => { - if ((event.key == ' ' || event.key == 'Enter')) { - this.performAction(event); - } - }); + CoreDom.onActivate(this.element, (event) => this.performAction(event)); } /** diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts index 914cc6ff2..2544650d0 100644 --- a/src/core/singletons/dom.ts +++ b/src/core/singletons/dom.ts @@ -481,6 +481,29 @@ export class CoreDom { ); } + /** + * Listen to click and Enter/Space keys in an element. + * + * @param element Element to listen to events. + * @param callback Callback to call when clicked or the key is pressed. + */ + static onActivate(element: HTMLElement, callback: (event: MouseEvent | KeyboardEvent) => void): void { + element.addEventListener('click', (event) => callback(event)); + + element.addEventListener('keydown', (event) => { + if ((event.key == ' ' || event.key == 'Enter')) { + event.preventDefault(); + event.stopPropagation(); + } + }); + + element.addEventListener('keyup', (event) => { + if ((event.key == ' ' || event.key == 'Enter')) { + callback(event); + } + }); + } + } /** From dc6ca1f08549ea01ff0b8c085d53a0cca9eb9d4c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 1 Apr 2022 11:48:54 +0200 Subject: [PATCH 2/3] MOBILE-4015 core: Support refresh on resume with data-app-url --- src/core/directives/format-text.ts | 33 +++++++++++++++---- .../course/pages/contents/contents.ts | 29 ++++++++++++---- src/core/singletons/events.ts | 25 ++++++++++++++ src/core/utils/refresh-context.ts | 27 +++++++++++++++ 4 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 src/core/utils/refresh-context.ts diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 7b4d8a7ea..26d5c741a 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -24,6 +24,7 @@ import { ViewContainerRef, ViewChild, OnDestroy, + Inject, } from '@angular/core'; import { IonContent } from '@ionic/angular'; @@ -33,7 +34,7 @@ import { CoreIframeUtils, CoreIframeUtilsProvider } from '@services/utils/iframe import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import { CoreSite } from '@classes/site'; -import { Translate } from '@singletons'; +import { NgZone, Platform, Translate } from '@singletons'; import { CoreExternalContentDirective } from './external-content'; import { CoreLinkDirective } from './link'; import { CoreFilter, CoreFilterFilter, CoreFilterFormatTextOptions } from '@features/filter/services/filter'; @@ -46,6 +47,8 @@ import { CoreCancellablePromise } from '@classes/cancellable-promise'; import { AsyncComponent } from '@classes/async-component'; import { CoreText } from '@singletons/text'; import { CoreDom } from '@singletons/dom'; +import { CoreEvents } from '@singletons/events'; +import { CoreRefreshContext, CORE_REFRESH_CONTEXT } from '@/core/utils/refresh-context'; /** * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective @@ -99,6 +102,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo element: ElementRef, @Optional() protected content: IonContent, protected viewContainerRef: ViewContainerRef, + @Optional() @Inject(CORE_REFRESH_CONTEXT) protected refreshContext?: CoreRefreshContext, ) { CoreComponentsRegistry.register(element.nativeElement, this); @@ -457,8 +461,8 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo // Walk through the content to find the links and add our directive to it. // Important: We need to look for links first because in 'img' we add new links without core-link. anchors.forEach((anchor) => { - if (anchor.getAttribute('data-app-url')) { - // Link already treated in data-app-url, ignore it. + if (anchor.dataset.appUrl) { + // Link already treated in treatAppUrlElements, ignore it. return; } @@ -563,7 +567,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo const appUrlElements = Array.from(div.querySelectorAll('*[data-app-url]')); appUrlElements.forEach((element) => { - const url = element.getAttribute('data-app-url'); + const url = element.dataset.appUrl; if (!url) { return; } @@ -582,8 +586,9 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo return; } - const confirmMessage = element.getAttribute('data-app-url-confirm'); - const openInApp = element.getAttribute('data-open-in') === 'app'; + const confirmMessage = element.dataset.appUrlConfirm; + const openInApp = element.dataset.openIn === 'app'; + const refreshOnResume = element.dataset.appUrlResumeAction === 'refresh'; if (confirmMessage) { try { @@ -595,10 +600,26 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo if (openInApp) { site.openInAppWithAutoLoginIfSameSite(url); + + if (refreshOnResume && this.refreshContext) { + // Refresh the context when the IAB is closed. + CoreEvents.once(CoreEvents.IAB_EXIT, () => { + this.refreshContext?.refreshContext(); + }); + } } else { site.openInBrowserWithAutoLoginIfSameSite(url, undefined, { showBrowserWarning: !confirmMessage, }); + + if (refreshOnResume && this.refreshContext) { + // Refresh the context when the app is resumed. + CoreSubscriptions.once(Platform.resume, () => { + NgZone.run(async () => { + this.refreshContext?.refreshContext(); + }); + }); + } } }); }); diff --git a/src/core/features/course/pages/contents/contents.ts b/src/core/features/course/pages/contents/contents.ts index 7413a0444..42a653ace 100644 --- a/src/core/features/course/pages/contents/contents.ts +++ b/src/core/features/course/pages/contents/contents.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core'; +import { Component, ViewChild, OnInit, OnDestroy, forwardRef } from '@angular/core'; import { IonContent, IonRefresher } from '@ionic/angular'; import { CoreDomUtils } from '@services/utils/dom'; @@ -36,6 +36,7 @@ import { CoreEventObserver, } from '@singletons/events'; import { CoreNavigator } from '@services/navigator'; +import { CoreRefreshContext, CORE_REFRESH_CONTEXT } from '@/core/utils/refresh-context'; /** * Page that displays the contents of a course. @@ -43,8 +44,12 @@ import { CoreNavigator } from '@services/navigator'; @Component({ selector: 'page-core-course-contents', templateUrl: 'contents.html', + providers: [{ + provide: CORE_REFRESH_CONTEXT, + useExisting: forwardRef(() => CoreCourseContentsPage), + }], }) -export class CoreCourseContentsPage implements OnInit, OnDestroy { +export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshContext { @ViewChild(IonContent) content?: IonContent; @ViewChild(CoreCourseFormatComponent) formatComponent?: CoreCourseFormatComponent; @@ -127,7 +132,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { CoreEvents.COMPLETION_MODULE_VIEWED, (data) => { if (data && data.courseId == this.course.id) { - this.refreshAfterCompletionChange(true); + this.showLoadingAndRefresh(true, false); } }, ); @@ -141,7 +146,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { return; } - this.refreshAfterCompletionChange(false); + this.showLoadingAndRefresh(false, false); if (data.warnings && data.warnings[0]) { CoreDomUtils.showErrorModal(data.warnings[0]); @@ -315,7 +320,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { await CoreUtils.ignoreErrors(this.invalidateData()); - await this.refreshAfterCompletionChange(true); + await this.showLoadingAndRefresh(true, false); } /** @@ -341,9 +346,10 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { * Refresh list after a completion change since there could be new activities. * * @param sync If it should try to sync. + * @param invalidateData Whether to invalidate data. Set it to false if data has already been invalidated. * @return Promise resolved when done. */ - protected async refreshAfterCompletionChange(sync?: boolean): Promise { + protected async showLoadingAndRefresh(sync = false, invalidateData = true): Promise { // Save scroll position to restore it once done. const scrollElement = await this.content?.getScrollElement(); const scrollTop = scrollElement?.scrollTop || 0; @@ -353,6 +359,10 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { this.content?.scrollToTop(0); // Scroll top so the spinner is seen. try { + if (invalidateData) { + await CoreUtils.ignoreErrors(this.invalidateData()); + } + await this.loadData(true, sync); await this.formatComponent?.doRefresh(undefined, undefined, true); @@ -366,6 +376,13 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { } } + /** + * @inheritdoc + */ + async refreshContext(): Promise { + await this.showLoadingAndRefresh(true, true); + } + /** * @inheritdoc */ diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index d1d805e6c..9f303efa7 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -173,6 +173,31 @@ export class CoreEvents { }; } + /** + * Listen once for a certain event. To stop listening to the event (in case it wasn't triggered): + * let observer = eventsProvider.on('something', myCallBack); + * ... + * observer.off(); + * + * @param eventName Name of the event to listen to. + * @param callBack Function to call when the event is triggered. + * @param siteId Site where to trigger the event. Undefined won't check the site. + * @return Observer to stop listening. + */ + static once( + eventName: Event, + callBack: (value: CoreEventData & CoreEventSiteData) => void, + siteId?: string, + ): CoreEventObserver { + const listener = CoreEvents.on(eventName, (value) => { + setTimeout(() => listener.off(), 0); + + callBack(value); + }, siteId); + + return listener; + } + /** * Listen for several events. To stop listening to the events: * let observer = eventsProvider.onMultiple(['something', 'another'], myCallBack); diff --git a/src/core/utils/refresh-context.ts b/src/core/utils/refresh-context.ts new file mode 100644 index 000000000..a0cfbed71 --- /dev/null +++ b/src/core/utils/refresh-context.ts @@ -0,0 +1,27 @@ +// (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 } from '@angular/core'; + +/** + * Context to refresh data when a certain action happens. + */ +export interface CoreRefreshContext { + /** + * Refresh the context. + */ + refreshContext(): Promise; +} + +export const CORE_REFRESH_CONTEXT = new InjectionToken('CORE_REFRESH_CONTEXT'); From e85acc68688bc06d78987d07684cb1cf3940ceb1 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 1 Apr 2022 12:21:23 +0200 Subject: [PATCH 3/3] MOBILE-4015 course: Fix keep scroll when refresh --- src/core/features/course/pages/contents/contents.html | 4 ++-- src/core/features/course/pages/contents/contents.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/features/course/pages/contents/contents.html b/src/core/features/course/pages/contents/contents.html index 2d0eeb1db..81b1e7dc0 100644 --- a/src/core/features/course/pages/contents/contents.html +++ b/src/core/features/course/pages/contents/contents.html @@ -1,9 +1,9 @@ - + - + diff --git a/src/core/features/course/pages/contents/contents.ts b/src/core/features/course/pages/contents/contents.ts index 42a653ace..b190f4ec0 100644 --- a/src/core/features/course/pages/contents/contents.ts +++ b/src/core/features/course/pages/contents/contents.ts @@ -59,6 +59,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon sectionId?: number; sectionNumber?: number; dataLoaded = false; + updatingData = false; downloadCourseEnabled = false; moduleId?: number; displayEnableDownload = false; @@ -355,7 +356,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon const scrollTop = scrollElement?.scrollTop || 0; const scrollLeft = scrollElement?.scrollLeft || 0; - this.dataLoaded = false; + this.updatingData = true; this.content?.scrollToTop(0); // Scroll top so the spinner is seen. try { @@ -367,7 +368,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon await this.formatComponent?.doRefresh(undefined, undefined, true); } finally { - this.dataLoaded = true; + this.updatingData = false; // Wait for new content height to be calculated and scroll without animation. setTimeout(() => {