Merge pull request #2814 from NoelDeMartin/MOBILE-3320

MOBILE-3320: Forum improvements
main
Dani Palou 2021-06-09 08:10:59 +02:00 committed by GitHub
commit 1cfadedeec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 114 additions and 105 deletions

View File

@ -566,14 +566,14 @@ export class AddonCalendarProvider {
*/ */
async getCalendarLookAhead(siteId?: string): Promise<number> { async getCalendarLookAhead(siteId?: string): Promise<number> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
let value: string | undefined; let value: string | undefined | null;
try { try {
value = await CoreUser.getUserPreference('calendar_lookahead'); value = await CoreUser.getUserPreference('calendar_lookahead');
} catch { } catch {
// Ignore errors. // Ignore errors.
} }
if (typeof value == 'undefined') { if (typeof value == 'undefined' || value === null) {
value = site.getStoredConfig('calendar_lookahead'); value = site.getStoredConfig('calendar_lookahead');
} }
@ -588,7 +588,7 @@ export class AddonCalendarProvider {
*/ */
async getCalendarTimeFormat(siteId?: string): Promise<string> { async getCalendarTimeFormat(siteId?: string): Promise<string> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
let format: string | undefined; let format: string | undefined | null;
try { try {
format = await CoreUser.getUserPreference('calendar_timeformat'); format = await CoreUser.getUserPreference('calendar_timeformat');

View File

@ -24,7 +24,7 @@
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label></ion-label> <ion-label class="sr-only">{{ plugin.name }}</ion-label>
<core-rich-text-editor [control]="control" [placeholder]="plugin.name" <core-rich-text-editor [control]="control" [placeholder]="plugin.name"
name="onlinetext_editor_text" (contentChanged)="onChange($event)" [component]="component" name="onlinetext_editor_text" (contentChanged)="onChange($event)" [component]="component"
[componentId]="assign.cmid" [autoSave]="true" contextLevel="module" [contextInstanceId]="assign.cmid" [componentId]="assign.cmid" [autoSave]="true" contextLevel="module" [contextInstanceId]="assign.cmid"

View File

@ -16,11 +16,12 @@ import { AddonModDataFieldPluginComponent } from '@addons/mod/data/classes/field
import { AddonModDataEntryField } from '@addons/mod/data/services/data'; import { AddonModDataEntryField } from '@addons/mod/data/services/data';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { SafeUrl } from '@angular/platform-browser';
import { CoreAnyError } from '@classes/errors/error'; import { CoreAnyError } from '@classes/errors/error';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreGeolocation, CoreGeolocationError, CoreGeolocationErrorReason } from '@services/geolocation'; import { CoreGeolocation, CoreGeolocationError, CoreGeolocationErrorReason } from '@services/geolocation';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { DomSanitizer } from '@singletons';
/** /**
* Component to render data latlong field. * Component to render data latlong field.
@ -35,10 +36,7 @@ export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginCo
east?: number; east?: number;
locationServicesEnabled = false; locationServicesEnabled = false;
constructor( constructor(fb: FormBuilder) {
fb: FormBuilder,
protected sanitizer: DomSanitizer,
) {
super(fb); super(fb);
} }
@ -82,7 +80,7 @@ export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginCo
} }
} }
return this.sanitizer.bypassSecurityTrustUrl(url); return DomSanitizer.bypassSecurityTrustUrl(url);
} }
/** /**

View File

@ -11,12 +11,12 @@
[priority]="750" content="{{'addon.blog.blog' | translate}}" [iconAction]="'far-newspaper'" (action)="gotoBlog()"> [priority]="750" content="{{'addon.blog.blog' | translate}}" [iconAction]="'far-newspaper'" (action)="gotoBlog()">
</core-context-menu-item> </core-context-menu-item>
<core-context-menu-item <core-context-menu-item
*ngIf="discussions.onlineLoaded && discussions.loaded && !(hasOffline || hasOfflineRatings) && isOnline" *ngIf="discussions.loaded && !(hasOffline || hasOfflineRatings) && isOnline"
[priority]="700" [content]="'addon.mod_forum.refreshdiscussions' | translate" [iconAction]="refreshIcon" [closeOnClick]="false" [priority]="700" [content]="'addon.mod_forum.refreshdiscussions' | translate" [iconAction]="refreshIcon" [closeOnClick]="false"
(action)="doRefresh(null, $event)"> (action)="doRefresh(null, $event)">
</core-context-menu-item> </core-context-menu-item>
<core-context-menu-item <core-context-menu-item
*ngIf="discussions.onlineLoaded && discussions.loaded && (hasOffline || hasOfflineRatings) && isOnline" *ngIf="discussions.loaded && (hasOffline || hasOfflineRatings) && isOnline"
[priority]="600" [content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false" [priority]="600" [content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false"
(action)="doRefresh(null, $event, true)"> (action)="doRefresh(null, $event, true)">
</core-context-menu-item> </core-context-menu-item>
@ -39,11 +39,11 @@
<!-- Content. --> <!-- Content. -->
<core-split-view> <core-split-view>
<ion-refresher slot="fixed" [disabled]="!discussions.onlineLoaded || !discussions.loaded" (ionRefresh)="doRefresh($event.target)"> <ion-refresher slot="fixed" [disabled]="!discussions.loaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher> </ion-refresher>
<core-loading [hideUntil]="discussions.onlineLoaded && discussions.loaded"> <core-loading [hideUntil]="discussions.loaded">
<!-- Activity info. --> <!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true" <core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()"> (completionChanged)="onCompletionChange()">
@ -158,7 +158,7 @@
</ion-item> </ion-item>
<core-infinite-loading <core-infinite-loading
[enabled]="discussions.onlineLoaded && !discussions.completed" [error]="fetchMoreDiscussionsFailed" [enabled]="discussions.onlineLoaded && !discussions.completed" [error]="discussions.fetchFailed"
(action)="fetchMoreDiscussions($event)"> (action)="fetchMoreDiscussions($event)">
</core-infinite-loading> </core-infinite-loading>
</ng-container> </ng-container>

View File

@ -73,7 +73,6 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
moduleName = 'forum'; moduleName = 'forum';
descriptionNote?: string; descriptionNote?: string;
forum?: AddonModForumData; forum?: AddonModForumData;
fetchMoreDiscussionsFailed = false;
discussions: AddonModForumDiscussionsManager; discussions: AddonModForumDiscussionsManager;
canAddDiscussion = false; canAddDiscussion = false;
addDiscussionText!: string; addDiscussionText!: string;
@ -237,7 +236,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
* @param showErrors Wether to show errors to the user or hide them. * @param showErrors Wether to show errors to the user or hide them.
*/ */
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> { protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
this.fetchMoreDiscussionsFailed = false; this.discussions.fetchFailed = false;
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
@ -259,7 +258,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
if (refresh) { if (refresh) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true);
this.fetchMoreDiscussionsFailed = true; // Set to prevent infinite calls with infinite-loading. this.discussions.fetchFailed = true; // Set to prevent infinite calls with infinite-loading.
} else { } else {
// Get forum failed, retry without using cache since it might be a new activity. // Get forum failed, retry without using cache since it might be a new activity.
await this.refreshContent(sync); await this.refreshContent(sync);
@ -422,7 +421,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
*/ */
protected async fetchDiscussions(refresh: boolean): Promise<void> { protected async fetchDiscussions(refresh: boolean): Promise<void> {
const forum = this.forum!; const forum = this.forum!;
this.fetchMoreDiscussionsFailed = false; this.discussions.fetchFailed = false;
if (refresh) { if (refresh) {
this.page = 0; this.page = 0;
@ -497,7 +496,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true);
this.fetchMoreDiscussionsFailed = true; this.discussions.fetchFailed = true;
} finally { } finally {
complete(); complete();
} }
@ -715,6 +714,7 @@ type DiscussionItem = AddonModForumDiscussion | AddonModForumOfflineDiscussion |
class AddonModForumDiscussionsManager extends CorePageItemsListManager<DiscussionItem> { class AddonModForumDiscussionsManager extends CorePageItemsListManager<DiscussionItem> {
onlineLoaded = false; onlineLoaded = false;
fetchFailed = false;
private discussionsPathPrefix: string; private discussionsPathPrefix: string;
private component: AddonModForumIndexComponent; private component: AddonModForumIndexComponent;
@ -726,6 +726,10 @@ class AddonModForumDiscussionsManager extends CorePageItemsListManager<Discussio
this.discussionsPathPrefix = discussionsPathPrefix; this.discussionsPathPrefix = discussionsPathPrefix;
} }
get loaded(): boolean {
return super.loaded && (this.onlineLoaded || this.fetchFailed);
}
get onlineDiscussions(): AddonModForumDiscussion[] { get onlineDiscussions(): AddonModForumDiscussion[] {
return this.items.filter(discussion => this.isOnlineDiscussion(discussion)) as AddonModForumDiscussion[]; return this.items.filter(discussion => this.isOnlineDiscussion(discussion)) as AddonModForumDiscussion[];
} }
@ -782,6 +786,7 @@ class AddonModForumDiscussionsManager extends CorePageItemsListManager<Discussio
const otherDiscussions = this.items.filter(discussion => !this.isOnlineDiscussion(discussion)); const otherDiscussions = this.items.filter(discussion => !this.isOnlineDiscussion(discussion));
this.setItems(otherDiscussions.concat(onlineDiscussions), hasMoreItems); this.setItems(otherDiscussions.concat(onlineDiscussions), hasMoreItems);
this.onlineLoaded = true;
} }
/** /**
@ -795,15 +800,6 @@ class AddonModForumDiscussionsManager extends CorePageItemsListManager<Discussio
this.setItems((offlineDiscussions as DiscussionItem[]).concat(otherDiscussions), this.hasMoreItems); this.setItems((offlineDiscussions as DiscussionItem[]).concat(otherDiscussions), this.hasMoreItems);
} }
/**
* @inheritdoc
*/
setItems(discussions: DiscussionItem[], hasMoreItems: boolean = false): void {
super.setItems(discussions, hasMoreItems);
this.onlineLoaded = this.onlineLoaded || discussions.some(discussion => this.isOnlineDiscussion(discussion));
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@ -395,7 +395,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
offlineReplies.push(reply); offlineReplies.push(reply);
// Disable reply of the parent. Reply in offline to the same post is not allowed, edit instead. // Disable reply of the parent. Reply in offline to the same post is not allowed, edit instead.
posts[reply.parentid!].capabilities.reply = false; onlinePostsMap[reply.parentid!].capabilities.reply = false;
return; return;
}), }),

View File

@ -13,7 +13,6 @@
// limitations under the License. // limitations under the License.
import { Injectable, Type } from '@angular/core'; import { Injectable, Type } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate';
@ -24,7 +23,7 @@ import { CoreFilepool } from '@services/filepool';
import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons'; import { DomSanitizer, makeSingleton } from '@singletons';
import { AddonModLtiHelper } from '../lti-helper'; import { AddonModLtiHelper } from '../lti-helper';
import { AddonModLti, AddonModLtiProvider } from '../lti'; import { AddonModLti, AddonModLtiProvider } from '../lti';
import { AddonModLtiIndexComponent } from '../../components/index'; import { AddonModLtiIndexComponent } from '../../components/index';
@ -51,8 +50,6 @@ export class AddonModLtiModuleHandlerService implements CoreCourseModuleHandler
[CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true,
}; };
constructor(protected sanitizer: DomSanitizer) {}
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -124,11 +121,11 @@ export class AddonModLtiModuleHandlerService implements CoreCourseModuleHandler
// Get the internal URL. // Get the internal URL.
const url = await CoreFilepool.getSrcByUrl(siteId, icon, AddonModLtiProvider.COMPONENT, module.id); const url = await CoreFilepool.getSrcByUrl(siteId, icon, AddonModLtiProvider.COMPONENT, module.id);
handlerData.icon = this.sanitizer.bypassSecurityTrustUrl(url); handlerData.icon = DomSanitizer.bypassSecurityTrustUrl(url);
} catch { } catch {
// Error downloading. If we're online we'll set the online url. // Error downloading. If we're online we'll set the online url.
if (CoreApp.isOnline()) { if (CoreApp.isOnline()) {
handlerData.icon = this.sanitizer.bypassSecurityTrustUrl(icon); handlerData.icon = DomSanitizer.bypassSecurityTrustUrl(icon);
} }
} }
} }

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'; import { AfterViewInit, ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core';
import { IonRouterOutlet } from '@ionic/angular'; import { IonRouterOutlet } from '@ionic/angular';
import { BackButtonEvent } from '@ionic/core'; import { BackButtonEvent } from '@ionic/core';
@ -20,7 +20,7 @@ import { CoreLang } from '@services/lang';
import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreLoginHelper } from '@features/login/services/login-helper';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { Network, NgZone, Platform, SplashScreen } from '@singletons'; import { Network, NgZone, Platform, SplashScreen } from '@singletons';
import { CoreApp } from '@services/app'; import { CoreApp, CoreAppProvider } from '@services/app';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreSubscriptions } from '@singletons/subscriptions'; import { CoreSubscriptions } from '@singletons/subscriptions';
@ -34,6 +34,10 @@ import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins';
const MOODLE_VERSION_PREFIX = 'version-'; const MOODLE_VERSION_PREFIX = 'version-';
const MOODLEAPP_VERSION_PREFIX = 'moodleapp-'; const MOODLEAPP_VERSION_PREFIX = 'moodleapp-';
type AutomatedTestsWindow = Window & {
changeDetector?: ChangeDetectorRef;
};
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: 'app.component.html', templateUrl: 'app.component.html',
@ -46,6 +50,12 @@ export class AppComponent implements OnInit, AfterViewInit {
protected lastUrls: Record<string, number> = {}; protected lastUrls: Record<string, number> = {};
protected lastInAppUrl?: string; protected lastInAppUrl?: string;
constructor(changeDetector: ChangeDetectorRef) {
if (CoreAppProvider.isAutomated()) {
(window as AutomatedTestsWindow).changeDetector = changeDetector;
}
}
/** /**
* Component being initialized. * Component being initialized.
* *

View File

@ -15,7 +15,7 @@
import { import {
Component, Input, Output, ViewChild, ElementRef, EventEmitter, OnChanges, SimpleChange, Component, Input, Output, ViewChild, ElementRef, EventEmitter, OnChanges, SimpleChange,
} from '@angular/core'; } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { SafeResourceUrl } from '@angular/platform-browser';
import { CoreFile } from '@services/file'; import { CoreFile } from '@services/file';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
@ -23,6 +23,7 @@ import { CoreUrlUtils } from '@services/utils/url';
import { CoreIframeUtils } from '@services/utils/iframe'; import { CoreIframeUtils } from '@services/utils/iframe';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreLogger } from '@singletons/logger'; import { CoreLogger } from '@singletons/logger';
import { DomSanitizer } from '@singletons';
@Component({ @Component({
selector: 'core-iframe', selector: 'core-iframe',
@ -46,10 +47,7 @@ export class CoreIframeComponent implements OnChanges {
protected logger: CoreLogger; protected logger: CoreLogger;
protected initialized = false; protected initialized = false;
constructor( constructor() {
protected sanitizer: DomSanitizer,
) {
this.logger = CoreLogger.getInstance('CoreIframe'); this.logger = CoreLogger.getInstance('CoreIframe');
this.loaded = new EventEmitter<HTMLIFrameElement>(); this.loaded = new EventEmitter<HTMLIFrameElement>();
} }
@ -105,7 +103,7 @@ export class CoreIframeComponent implements OnChanges {
await CoreIframeUtils.fixIframeCookies(url); await CoreIframeUtils.fixIframeCookies(url);
this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(CoreFile.convertFileSrc(url)); this.safeUrl = DomSanitizer.bypassSecurityTrustResourceUrl(CoreFile.convertFileSrc(url));
// Now that the URL has been set, initialize the iframe. Wait for the iframe to the added to the DOM. // Now that the URL has been set, initialize the iframe. Wait for the iframe to the added to the DOM.
setTimeout(() => { setTimeout(() => {

View File

@ -13,8 +13,8 @@
// limitations under the License. // limitations under the License.
import { Component, Input, OnChanges, SimpleChange, ChangeDetectionStrategy } from '@angular/core'; import { Component, Input, OnChanges, SimpleChange, ChangeDetectionStrategy } from '@angular/core';
import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; import { SafeStyle } from '@angular/platform-browser';
import { Translate } from '@singletons'; import { DomSanitizer, Translate } from '@singletons';
/** /**
* Component to show a progress bar and its value. * Component to show a progress bar and its value.
@ -40,8 +40,6 @@ export class CoreProgressBarComponent implements OnChanges {
protected textSupplied = false; protected textSupplied = false;
constructor(private sanitizer: DomSanitizer) { }
/** /**
* Detect changes on input properties. * Detect changes on input properties.
*/ */
@ -69,7 +67,7 @@ export class CoreProgressBarComponent implements OnChanges {
this.text = String(this.progress); this.text = String(this.progress);
} }
this.width = this.sanitizer.bypassSecurityTrustStyle(this.progress + '%'); this.width = DomSanitizer.bypassSecurityTrustStyle(this.progress + '%');
} }
} }

View File

@ -23,7 +23,6 @@ import {
Optional, Optional,
ViewContainerRef, ViewContainerRef,
} from '@angular/core'; } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { IonContent } from '@ionic/angular'; import { IonContent } from '@ionic/angular';
import { CoreEventLoadingChangedData, CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventLoadingChangedData, CoreEventObserver, CoreEvents } from '@singletons/events';
@ -94,7 +93,6 @@ export class CoreFormatTextDirective implements OnChanges {
element: ElementRef, element: ElementRef,
@Optional() protected content: IonContent, @Optional() protected content: IonContent,
protected viewContainerRef: ViewContainerRef, protected viewContainerRef: ViewContainerRef,
protected sanitizer: DomSanitizer,
) { ) {
this.element = element.nativeElement; this.element = element.nativeElement;
this.element.classList.add('core-format-text-loading'); // Hide contents until they're treated. this.element.classList.add('core-format-text-loading'); // Hide contents until they're treated.
@ -520,7 +518,7 @@ export class CoreFormatTextDirective implements OnChanges {
// Important: We need to look for links first because in 'img' we add new links without core-link. // Important: We need to look for links first because in 'img' we add new links without core-link.
anchors.forEach((anchor) => { anchors.forEach((anchor) => {
// Angular 2 doesn't let adding directives dynamically. Create the CoreLinkDirective manually. // Angular 2 doesn't let adding directives dynamically. Create the CoreLinkDirective manually.
const linkDir = new CoreLinkDirective(new ElementRef(anchor), this.content, this.sanitizer); const linkDir = new CoreLinkDirective(new ElementRef(anchor), this.content);
linkDir.capture = this.captureLinks ?? true; linkDir.capture = this.captureLinks ?? true;
linkDir.inApp = this.openLinksInApp; linkDir.inApp = this.openLinksInApp;
linkDir.ngOnInit(); linkDir.ngOnInit();

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
import { Directive, Input, OnInit, ElementRef, Optional, SecurityContext } from '@angular/core'; import { Directive, Input, OnInit, ElementRef, Optional, SecurityContext } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { SafeUrl } from '@angular/platform-browser';
import { IonContent } from '@ionic/angular'; import { IonContent } from '@ionic/angular';
import { CoreFileHelper } from '@services/file-helper'; import { CoreFileHelper } from '@services/file-helper';
@ -25,6 +25,7 @@ import { CoreTextUtils } from '@services/utils/text';
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { CoreCustomURLSchemes } from '@services/urlschemes'; import { CoreCustomURLSchemes } from '@services/urlschemes';
import { DomSanitizer } from '@singletons';
/** /**
* Directive to open a link in external browser or in the app. * Directive to open a link in external browser or in the app.
@ -48,7 +49,6 @@ export class CoreLinkDirective implements OnInit {
constructor( constructor(
element: ElementRef, element: ElementRef,
@Optional() protected content: IonContent, @Optional() protected content: IonContent,
protected sanitizer: DomSanitizer,
) { ) {
this.element = element.nativeElement; this.element = element.nativeElement;
} }
@ -96,7 +96,7 @@ export class CoreLinkDirective implements OnInit {
let href: string | null = null; let href: string | null = null;
if (this.href) { if (this.href) {
// Convert the URL back to string if needed. // Convert the URL back to string if needed.
href = typeof this.href === 'string' ? this.href : this.sanitizer.sanitize(SecurityContext.URL, this.href); href = typeof this.href === 'string' ? this.href : DomSanitizer.sanitize(SecurityContext.URL, this.href);
} }
href = href || this.element.getAttribute('href') || this.element.getAttribute('xlink:href'); href = href || this.element.getAttribute('href') || this.element.getAttribute('xlink:href');

View File

@ -12,7 +12,6 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { DomSanitizer } from '@angular/platform-browser';
import { IonContent } from '@ionic/angular'; import { IonContent } from '@ionic/angular';
import { NgZone } from '@angular/core'; import { NgZone } from '@angular/core';
import Faker from 'faker'; import Faker from 'faker';
@ -39,7 +38,7 @@ describe('CoreFormatTextDirective', () => {
mockSingleton(Platform, { ready: () => Promise.resolve() }); mockSingleton(Platform, { ready: () => Promise.resolve() });
mockSingleton(CoreConfig, { get: (_, defaultValue) => defaultValue }); mockSingleton(CoreConfig, { get: (_, defaultValue) => defaultValue });
CoreDomUtils.setInstance(new CoreDomUtilsProvider(mock<DomSanitizer>())); CoreDomUtils.setInstance(new CoreDomUtilsProvider());
CoreUrlUtils.setInstance(new CoreUrlUtilsProvider()); CoreUrlUtils.setInstance(new CoreUrlUtilsProvider());
CoreUtils.setInstance(new CoreUtilsProvider(mock<NgZone>())); CoreUtils.setInstance(new CoreUtilsProvider(mock<NgZone>()));

View File

@ -136,7 +136,7 @@ export class CoreCourseActivityPrefetchHandlerBase extends CoreCourseModulePrefe
await this.setDownloaded(module.id, siteId, extra); await this.setDownloaded(module.id, siteId, extra);
} catch (error) { } catch (error) {
// Error prefetching, go back to previous status and reject the promise. // Error prefetching, go back to previous status and reject the promise.
return this.setPreviousStatus(module.id, siteId); await this.setPreviousStatus(module.id, siteId);
throw error; throw error;
} }

View File

@ -5,6 +5,7 @@
class="core-rte-editor" class="core-rte-editor"
role="textbox" role="textbox"
contenteditable="true" contenteditable="true"
[attr.aria-labelledby]="ariaLabelledBy"
[attr.data-placeholder-text]="placeholder" [attr.data-placeholder-text]="placeholder"
(focus)="showToolbar($event)" (focus)="showToolbar($event)"
(blur)="hideToolbar($event)" (blur)="hideToolbar($event)"

View File

@ -23,6 +23,7 @@ import {
AfterContentInit, AfterContentInit,
OnDestroy, OnDestroy,
Optional, Optional,
AfterViewInit,
} from '@angular/core'; } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { IonTextarea, IonContent, IonSlides } from '@ionic/angular'; import { IonTextarea, IonContent, IonSlides } from '@ionic/angular';
@ -51,7 +52,7 @@ import { CoreEditorOffline } from '../../services/editor-offline';
templateUrl: 'core-editor-rich-text-editor.html', templateUrl: 'core-editor-rich-text-editor.html',
styleUrls: ['rich-text-editor.scss'], styleUrls: ['rich-text-editor.scss'],
}) })
export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentInit, OnDestroy { export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentInit, AfterViewInit, OnDestroy {
// Based on: https://github.com/judgewest2000/Ionic3RichText/ // Based on: https://github.com/judgewest2000/Ionic3RichText/
// @todo: Anchor button, fullscreen... // @todo: Anchor button, fullscreen...
@ -87,6 +88,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
protected valueChangeSubscription?: Subscription; protected valueChangeSubscription?: Subscription;
protected keyboardObserver?: CoreEventObserver; protected keyboardObserver?: CoreEventObserver;
protected resetObserver?: CoreEventObserver; protected resetObserver?: CoreEventObserver;
protected labelObserver?: MutationObserver;
protected initHeightInterval?: number; protected initHeightInterval?: number;
protected isCurrentView = true; protected isCurrentView = true;
protected toolbarButtonWidth = 44; protected toolbarButtonWidth = 44;
@ -109,6 +111,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
toolbarPrevHidden = true; toolbarPrevHidden = true;
toolbarNextHidden = false; toolbarNextHidden = false;
canScanQR = false; canScanQR = false;
ariaLabelledBy?: string;
infoMessage?: string; infoMessage?: string;
direction = 'ltr'; direction = 'ltr';
toolbarStyles = { toolbarStyles = {
@ -209,6 +212,20 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
} }
} }
/**
* @inheritdoc
*/
async ngAfterViewInit(): Promise<void> {
const label = this.element.closest('ion-item')?.querySelector('ion-label');
if (!label) {
return;
}
this.labelObserver = new MutationObserver(() => this.ariaLabelledBy = label.getAttribute('id') ?? undefined);
this.labelObserver.observe(label, { attributes: true, attributeFilter: ['id'] });
}
/** /**
* Set listeners and observers. * Set listeners and observers.
*/ */
@ -1118,6 +1135,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
this.resetObserver?.off(); this.resetObserver?.off();
this.keyboardObserver?.off(); this.keyboardObserver?.off();
this.labelObserver?.disconnect();
} }
} }

View File

@ -47,7 +47,7 @@ export class CoreRatingAggregateComponent implements OnChanges, OnDestroy {
disabled = false; disabled = false;
labelKey = ''; labelKey = '';
protected aggregateObserver: CoreEventObserver; protected aggregateObserver?: CoreEventObserver;
protected updateSiteObserver: CoreEventObserver; protected updateSiteObserver: CoreEventObserver;
constructor() { constructor() {
@ -57,27 +57,14 @@ export class CoreRatingAggregateComponent implements OnChanges, OnDestroy {
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.disabled = CoreRating.isRatingDisabledInSite(); this.disabled = CoreRating.isRatingDisabledInSite();
}, CoreSites.getCurrentSiteId()); }, CoreSites.getCurrentSiteId());
// Update aggrgate when the user adds or edits a rating.
this.aggregateObserver =
CoreEvents.on(CoreRatingProvider.AGGREGATE_CHANGED_EVENT, (data) => {
if (this.item &&
data.contextLevel == this.contextLevel &&
data.instanceId == this.instanceId &&
data.component == this.ratingInfo.component &&
data.ratingArea == this.ratingInfo.ratingarea &&
data.itemId == this.itemId) {
this.item.aggregatestr = data.aggregate;
this.item.count = data.count;
}
});
} }
/** /**
* Detect changes on input properties. * Detect changes on input properties.
*/ */
ngOnChanges(): void { ngOnChanges(): void {
this.aggregateObserver && this.aggregateObserver.off(); this.aggregateObserver?.off();
delete this.aggregateObserver;
this.item = (this.ratingInfo.ratings || []).find((rating) => rating.itemid == this.itemId); this.item = (this.ratingInfo.ratings || []).find((rating) => rating.itemid == this.itemId);
if (!this.item) { if (!this.item) {
@ -107,6 +94,20 @@ export class CoreRatingAggregateComponent implements OnChanges, OnDestroy {
} }
this.showCount = (this.aggregateMethod != CoreRatingProvider.AGGREGATE_COUNT); this.showCount = (this.aggregateMethod != CoreRatingProvider.AGGREGATE_COUNT);
// Update aggrgate when the user adds or edits a rating.
this.aggregateObserver =
CoreEvents.on(CoreRatingProvider.AGGREGATE_CHANGED_EVENT, (data) => {
if (this.item &&
data.contextLevel == this.contextLevel &&
data.instanceId == this.instanceId &&
data.component == this.ratingInfo.component &&
data.ratingArea == this.ratingInfo.ratingarea &&
data.itemId == this.itemId) {
this.item.aggregatestr = data.aggregate;
this.item.count = data.count;
}
});
} }
/** /**
@ -135,7 +136,7 @@ export class CoreRatingAggregateComponent implements OnChanges, OnDestroy {
* Component being destroyed. * Component being destroyed.
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.aggregateObserver.off(); this.aggregateObserver?.off();
this.updateSiteObserver.off(); this.updateSiteObserver.off();
} }

View File

@ -122,7 +122,8 @@ export class CoreRatingProvider {
try { try {
await CoreRatingOffline.deleteRating(component, ratingArea, contextLevel, instanceId, itemId, siteId); await CoreRatingOffline.deleteRating(component, ratingArea, contextLevel, instanceId, itemId, siteId);
this.addRatingOnline(
const response = await this.addRatingOnline(
component, component,
ratingArea, ratingArea,
contextLevel, contextLevel,
@ -134,6 +135,8 @@ export class CoreRatingProvider {
aggregateMethod, aggregateMethod,
siteId, siteId,
); );
return response;
} catch (error) { } catch (error) {
if (CoreUtils.isWebServiceError(error)) { if (CoreUtils.isWebServiceError(error)) {
// The WebService has thrown an error or offline not supported, reject. // The WebService has thrown an error or offline not supported, reject.

View File

@ -391,7 +391,7 @@ export class CoreUserProvider {
* @param siteId Site Id. If not defined, use current site. * @param siteId Site Id. If not defined, use current site.
* @return Preference value or null if preference not set. * @return Preference value or null if preference not set.
*/ */
async getUserPreference(name: string, siteId?: string): Promise<string> { async getUserPreference(name: string, siteId?: string): Promise<string | null> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const preference = await CoreUtils.ignoreErrors(CoreUserOffline.getPreference(name, siteId)); const preference = await CoreUtils.ignoreErrors(CoreUserOffline.getPreference(name, siteId));
@ -403,20 +403,15 @@ export class CoreUserProvider {
const wsValue = await this.getUserPreferenceOnline(name, siteId); const wsValue = await this.getUserPreferenceOnline(name, siteId);
if (!wsValue) {
if (preference) {
// Return the local value.
return preference.value;
}
throw new CoreError('Preference not found');
}
if (preference && preference.value != preference.onlinevalue && preference.onlinevalue == wsValue) { if (preference && preference.value != preference.onlinevalue && preference.onlinevalue == wsValue) {
// Sync is pending for this preference, return stored value. // Sync is pending for this preference, return stored value.
return preference.value; return preference.value;
} }
if (!wsValue) {
return null;
}
await CoreUserOffline.setPreference(name, wsValue, wsValue); await CoreUserOffline.setPreference(name, wsValue, wsValue);
return wsValue; return wsValue;

View File

@ -44,7 +44,7 @@ describe('CoreNavigator', () => {
mockSingleton(Router, router); mockSingleton(Router, router);
mockSingleton(CoreUtils, new CoreUtilsProvider(mock())); mockSingleton(CoreUtils, new CoreUtilsProvider(mock()));
mockSingleton(CoreUrlUtils, new CoreUrlUtilsProvider()); mockSingleton(CoreUrlUtils, new CoreUrlUtilsProvider());
mockSingleton(CoreTextUtils, new CoreTextUtilsProvider(mock())); mockSingleton(CoreTextUtils, new CoreTextUtilsProvider());
mockSingleton(CoreSites, { getCurrentSiteId: () => 42, isLoggedIn: () => true }); mockSingleton(CoreSites, { getCurrentSiteId: () => 42, isLoggedIn: () => true });
mockSingleton(CoreMainMenu, { isMainMenuTab: path => Promise.resolve(currentMainMenuHandlers.includes(path)) }); mockSingleton(CoreMainMenu, { isMainMenuTab: path => Promise.resolve(currentMainMenuHandlers.includes(path)) });
}); });

View File

@ -12,24 +12,22 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { DomSanitizer } from '@angular/platform-browser';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreTextUtilsProvider } from '@services/utils/text'; import { CoreTextUtilsProvider } from '@services/utils/text';
import { DomSanitizer } from '@singletons';
import { mock, mockSingleton } from '@/testing/utils'; import { mockSingleton } from '@/testing/utils';
describe('CoreTextUtilsProvider', () => { describe('CoreTextUtilsProvider', () => {
const config = { platform: 'android' }; const config = { platform: 'android' };
let sanitizer: DomSanitizer;
let textUtils: CoreTextUtilsProvider; let textUtils: CoreTextUtilsProvider;
beforeEach(() => { beforeEach(() => {
mockSingleton(CoreApp, [], { isAndroid: () => config.platform === 'android' }); mockSingleton(CoreApp, [], { isAndroid: () => config.platform === 'android' });
mockSingleton(DomSanitizer, [], { bypassSecurityTrustUrl: url => url });
sanitizer = mock<DomSanitizer>([], { bypassSecurityTrustUrl: url => url }); textUtils = new CoreTextUtilsProvider();
textUtils = new CoreTextUtilsProvider(sanitizer);
}); });
it('adds ending slashes', () => { it('adds ending slashes', () => {
@ -58,7 +56,7 @@ describe('CoreTextUtilsProvider', () => {
// Assert // Assert
expect(url).toEqual('geo:0,0?q=Moodle%20Spain%20HQ'); expect(url).toEqual('geo:0,0?q=Moodle%20Spain%20HQ');
expect(sanitizer.bypassSecurityTrustUrl).toHaveBeenCalled(); expect(DomSanitizer.bypassSecurityTrustUrl).toHaveBeenCalled();
expect(CoreApp.isAndroid).toHaveBeenCalled(); expect(CoreApp.isAndroid).toHaveBeenCalled();
}); });
@ -74,7 +72,7 @@ describe('CoreTextUtilsProvider', () => {
// Assert // Assert
expect(url).toEqual('http://maps.google.com?q=Moodle%20Spain%20HQ'); expect(url).toEqual('http://maps.google.com?q=Moodle%20Spain%20HQ');
expect(sanitizer.bypassSecurityTrustUrl).toHaveBeenCalled(); expect(DomSanitizer.bypassSecurityTrustUrl).toHaveBeenCalled();
expect(CoreApp.isAndroid).toHaveBeenCalled(); expect(CoreApp.isAndroid).toHaveBeenCalled();
}); });

View File

@ -13,7 +13,6 @@
// limitations under the License. // limitations under the License.
import { Injectable, SimpleChange, ElementRef, KeyValueChanges } from '@angular/core'; import { Injectable, SimpleChange, ElementRef, KeyValueChanges } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { IonContent } from '@ionic/angular'; import { IonContent } from '@ionic/angular';
import { ModalOptions, PopoverOptions, AlertOptions, AlertButton, TextFieldTypes } from '@ionic/core'; import { ModalOptions, PopoverOptions, AlertOptions, AlertButton, TextFieldTypes } from '@ionic/core';
import { Md5 } from 'ts-md5'; import { Md5 } from 'ts-md5';
@ -62,7 +61,7 @@ export class CoreDomUtilsProvider {
protected activeLoadingModals: CoreIonLoadingElement[] = []; protected activeLoadingModals: CoreIonLoadingElement[] = [];
protected logger: CoreLogger; protected logger: CoreLogger;
constructor(protected domSanitizer: DomSanitizer) { constructor() {
this.logger = CoreLogger.getInstance('CoreDomUtilsProvider'); this.logger = CoreLogger.getInstance('CoreDomUtilsProvider');
this.init(); this.init();

View File

@ -13,13 +13,13 @@
// limitations under the License. // limitations under the License.
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { SafeUrl } from '@angular/platform-browser';
import { ModalOptions } from '@ionic/core'; import { ModalOptions } from '@ionic/core';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreLang } from '@services/lang'; import { CoreLang } from '@services/lang';
import { CoreAnyError, CoreError } from '@classes/errors/error'; import { CoreAnyError, CoreError } from '@classes/errors/error';
import { makeSingleton, Translate } from '@singletons'; import { DomSanitizer, makeSingleton, Translate } from '@singletons';
import { CoreWSFile } from '@services/ws'; import { CoreWSFile } from '@services/ws';
import { Locutus } from '@singletons/locutus'; import { Locutus } from '@singletons/locutus';
import { CoreViewerTextComponent } from '@features/viewer/components/text/text'; import { CoreViewerTextComponent } from '@features/viewer/components/text/text';
@ -94,8 +94,6 @@ export class CoreTextUtilsProvider {
protected template: HTMLTemplateElement = document.createElement('template'); // A template element to convert HTML to element. protected template: HTMLTemplateElement = document.createElement('template'); // A template element to convert HTML to element.
constructor(private sanitizer: DomSanitizer) { }
/** /**
* Add ending slash from a path or URL. * Add ending slash from a path or URL.
* *
@ -156,7 +154,7 @@ export class CoreTextUtilsProvider {
* @return URL to view the address. * @return URL to view the address.
*/ */
buildAddressURL(address: string): SafeUrl { buildAddressURL(address: string): SafeUrl {
return this.sanitizer.bypassSecurityTrustUrl((CoreApp.isAndroid() ? 'geo:0,0?q=' : 'http://maps.google.com?q=') + return DomSanitizer.bypassSecurityTrustUrl((CoreApp.isAndroid() ? 'geo:0,0?q=' : 'http://maps.google.com?q=') +
encodeURIComponent(address)); encodeURIComponent(address));
} }

View File

@ -12,9 +12,10 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { ApplicationRef, ApplicationInitStatus, Injector, NgZone as NgZoneService, Type } from '@angular/core'; import { AbstractType, ApplicationInitStatus, ApplicationRef, Injector, NgZone as NgZoneService, Type } from '@angular/core';
import { Router as RouterService } from '@angular/router'; import { Router as RouterService } from '@angular/router';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { DomSanitizer as DomSanitizerService } from '@angular/platform-browser';
import { import {
Platform as PlatformService, Platform as PlatformService,
@ -109,7 +110,7 @@ export function setCreateSingletonMethodProxy(method: typeof createSingletonMeth
* @return Singleton proxy. * @return Singleton proxy.
*/ */
export function makeSingleton<Service extends object = object>( // eslint-disable-line @typescript-eslint/ban-types export function makeSingleton<Service extends object = object>( // eslint-disable-line @typescript-eslint/ban-types
injectionToken: Type<Service> | Type<unknown> | string, injectionToken: Type<Service> | AbstractType<Service> | Type<unknown> | string,
): CoreSingletonProxy<Service> { ): CoreSingletonProxy<Service> {
const singleton = { const singleton = {
setInstance(instance: Service) { setInstance(instance: Service) {
@ -199,6 +200,7 @@ export const ApplicationInit = makeSingleton(ApplicationInitStatus);
export const Application = makeSingleton(ApplicationRef); export const Application = makeSingleton(ApplicationRef);
export const NavController = makeSingleton(NavControllerService); export const NavController = makeSingleton(NavControllerService);
export const Router = makeSingleton(RouterService); export const Router = makeSingleton(RouterService);
export const DomSanitizer = makeSingleton(DomSanitizerService);
// Convert external libraries injectables. // Convert external libraries injectables.
export const Translate = makeSingleton(TranslateService); export const Translate = makeSingleton(TranslateService);