diff --git a/src/app/core/user/services/user.delegate.ts b/src/app/core/user/services/user.delegate.ts new file mode 100644 index 000000000..411a375f3 --- /dev/null +++ b/src/app/core/user/services/user.delegate.ts @@ -0,0 +1,283 @@ +// (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 { Injectable } from '@angular/core'; +import { NavController } from '@ionic/angular'; +import { Subject, BehaviorSubject } from 'rxjs'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEvents } from '@singletons/events'; +import { CoreUserProfile } from './user'; + +/** + * Interface that all user profile handlers must implement. + */ +export interface CoreUserProfileHandler extends CoreDelegateHandler { + /** + * The highest priority is displayed first. + */ + priority: number; + + /** + * A type should be specified among these: + * - TYPE_COMMUNICATION: will be displayed under the user avatar. Should have icon. Spinner not used. + * - TYPE_NEW_PAGE: will be displayed as a list of items. Should have icon. Spinner not used. + * Default value if none is specified. + * - TYPE_ACTION: will be displayed as a button and should not redirect to any state. Spinner use is recommended. + */ + type: string; + + /** + * Whether or not the handler is enabled for a user. + * + * @param user User object. + * @param courseId Course ID where to show. + * @param navOptions Navigation options for the course. + * @param admOptions Admin options for the course. + * @return Whether or not the handler is enabled for a user. + */ + isEnabledForUser(user: CoreUserProfile, courseId: number, navOptions?: unknown, admOptions?: unknown): Promise; + + /** + * Returns the data needed to render the handler. + * + * @param user User object. + * @param courseId Course ID where to show. + * @return Data to be shown. + */ + getDisplayData(user: CoreUserProfile, courseId: number): CoreUserProfileHandlerData; +} + +/** + * Data needed to render a user profile handler. It's returned by the handler. + */ +export interface CoreUserProfileHandlerData { + /** + * Title to display. + */ + title: string; + + /** + * Name of the icon to display. Mandatory for TYPE_COMMUNICATION. + */ + icon?: string; + + /** + * Additional class to add to the HTML. + */ + class?: string; + + /** + * If enabled, element will be hidden. Only for TYPE_NEW_PAGE and TYPE_ACTION. + */ + hidden?: boolean; + + /** + * If enabled will show an spinner. Only for TYPE_ACTION. + */ + spinner?: boolean; + + /** + * Action to do when clicked. + * + * @param event Click event. + * @param Nav controller to use to navigate. + * @param user User object. + * @param courseId Course ID being viewed. If not defined, site context. + */ + action(event: Event, navCtrl: NavController, user: CoreUserProfile, courseId?: number): void; +} + +/** + * Data returned by the delegate for each handler. + */ +export interface CoreUserProfileHandlerToDisplay { + /** + * Name of the handler. + */ + name?: string; + + /** + * Data to display. + */ + data: CoreUserProfileHandlerData; + + /** + * The highest priority is displayed first. + */ + priority?: number; + + /** + * The type of the handler. See CoreUserProfileHandler. + */ + type: string; +} + +/** + * Service to interact with plugins to be shown in user profile. Provides functions to register a plugin + * and notify an update in the data. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreUserDelegate extends CoreDelegate { + + /** + * User profile handler type for communication. + */ + static readonly TYPE_COMMUNICATION = 'communication'; + /** + * User profile handler type for new page. + */ + static readonly TYPE_NEW_PAGE = 'newpage'; + /** + * User profile handler type for actions. + */ + static readonly TYPE_ACTION = 'action'; + + /** + * Update handler information event. + */ + static readonly UPDATE_HANDLER_EVENT = 'CoreUserDelegate_update_handler_event'; + + protected featurePrefix = 'CoreUserDelegate_'; + + // Hold the handlers and the observable to notify them for each user. + protected userHandlers: { + [userId: number]: { + loaded: boolean; // Whether the handlers are loaded. + handlers: CoreUserProfileHandlerToDisplay[]; // List of handlers. + observable: Subject; // Observale to notify the handlers. + }; + } = {}; + + constructor() { + super('CoreUserDelegate', true); + + CoreEvents.on(CoreUserDelegate.UPDATE_HANDLER_EVENT, (data) => { + if (!data || !data.handler || !this.userHandlers[data.userId]) { + return; + } + + // Search the handler. + const handler = this.userHandlers[data.userId].handlers.find((userHandler) => userHandler.name == data.handler); + + if (!handler) { + return; + } + + // Update the data and notify. + Object.assign(handler.data, data.data); + this.userHandlers[data.userId].observable.next(this.userHandlers[data.userId].handlers); + }); + } + + /** + * Check if handlers are loaded. + * + * @return True if handlers are loaded, false otherwise. + */ + areHandlersLoaded(userId: number): boolean { + return this.userHandlers[userId]?.loaded; + } + + /** + * Clear current user handlers. + * + * @param userId The user to clear. + */ + clearUserHandlers(userId: number): void { + const userData = this.userHandlers[userId]; + + if (userData) { + userData.handlers = []; + userData.observable.next([]); + userData.loaded = false; + } + } + + /** + * Get the profile handlers for a user. + * + * @param user The user object. + * @param courseId The course ID. + * @return Resolved with the handlers. + */ + getProfileHandlersFor(user: CoreUserProfile, courseId: number): Subject { + // Initialize the user handlers if it isn't initialized already. + if (!this.userHandlers[user.id]) { + this.userHandlers[user.id] = { + loaded: false, + handlers: [], + observable: new BehaviorSubject([]), + }; + } + + this.calculateUserHandlers(user, courseId); + + return this.userHandlers[user.id].observable; + } + + /** + * Get the profile handlers for a user. + * + * @param user The user object. + * @param courseId The course ID. + * @return Promise resolved when done. + */ + protected async calculateUserHandlers(user: CoreUserProfile, courseId: number): Promise { + // @todo: Get Course admin/nav options. + let navOptions; + let admOptions; + + const userData = this.userHandlers[user.id]; + userData.handlers = []; + + await CoreUtils.instance.allPromises(Object.keys(this.enabledHandlers).map(async (name) => { + // Checks if the handler is enabled for the user. + const handler = this.handlers[name]; + + try { + const enabled = await handler.isEnabledForUser(user, courseId, navOptions, admOptions); + + if (enabled) { + userData.handlers.push({ + name: name, + data: handler.getDisplayData(user, courseId), + priority: handler.priority || 0, + type: handler.type || CoreUserDelegate.TYPE_NEW_PAGE, + }); + } + } catch { + // Nothing to do here, it is not enabled for this user. + } + })); + + // Sort them by priority. + userData.handlers.sort((a, b) => (b.priority || 0) - (a.priority || 0)); + userData.loaded = true; + userData.observable.next(userData.handlers); + } + +} + +/** + * Data passed to UPDATE_HANDLER_EVENT event. + */ +export type CoreUserUpdateHandlerData = { + handler: string; // Name of the handler. + userId: number; // User affected. + data: Record; // Data to set to the handler. +}; diff --git a/src/app/core/user/services/user.helper.ts b/src/app/core/user/services/user.helper.ts index d41a3e372..8403939f8 100644 --- a/src/app/core/user/services/user.helper.ts +++ b/src/app/core/user/services/user.helper.ts @@ -33,11 +33,11 @@ export class CoreUserHelperProvider { * @param country Country. * @return Formatted address. */ - formatAddress(address: string, city: string, country: string): string { + formatAddress(address?: string, city?: string, country?: string): string { const separator = Translate.instance.instant('core.listsep'); let values = [address, city, country]; - values = values.filter((value) => value?.length > 0); + values = values.filter((value) => value && value.length > 0); return values.join(separator + ' '); } diff --git a/src/app/core/user/services/user.ts b/src/app/core/user/services/user.ts index 7baae9152..cbfbe710c 100644 --- a/src/app/core/user/services/user.ts +++ b/src/app/core/user/services/user.ts @@ -21,7 +21,8 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreUserOffline } from './user.offline'; import { CoreLogger } from '@singletons/logger'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; -import { makeSingleton } from '@singletons/core.singletons'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreEvents, CoreEventUserDeletedData } from '@singletons/events'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; import { CoreError } from '@classes/errors/error'; import { USERS_TABLE_NAME, CoreUserDBRecord } from './user.db'; @@ -44,6 +45,25 @@ export class CoreUserProvider { constructor() { this.logger = CoreLogger.getInstance('CoreUserProvider'); + + CoreEvents.on(CoreEvents.USER_DELETED, (data) => { + // Search for userid in params. + let userId = 0; + + if (data.params.userid) { + userId = data.params.userid; + } else if (data.params.userids) { + userId = data.params.userids[0]; + } else if (data.params.field === 'id' && data.params.values && data.params.values.length) { + userId = data.params.values[0]; + } else if (data.params.userlist && data.params.userlist.length) { + userId = data.params.userlist[0].userid; + } + + if (userId > 0) { + this.deleteStoredUser(userId, data.siteId); + } + }); } /** @@ -72,6 +92,32 @@ export class CoreUserProvider { return !!site?.wsAvailable('core_enrol_search_users'); } + /** + * Check if WS to update profile picture is available in site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's available. + * @since 3.2 + */ + async canUpdatePicture(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canUpdatePictureInSite(site); + } + + /** + * Check if WS to search participants is available in site. + * + * @param site Site. If not defined, current site. + * @return Whether it's available. + * @since 3.2 + */ + canUpdatePictureInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_user_update_picture'); + } + /** * Change the given user profile picture. * @@ -199,7 +245,7 @@ export class CoreUserProvider { courseId?: number, forceLocal: boolean = false, siteId?: string, - ): Promise { + ): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); if (forceLocal) { @@ -291,7 +337,7 @@ export class CoreUserProvider { throw new CoreError('Cannot retrieve user info.'); } - const user = users[0]; + const user: CoreUserData | CoreUserCourseProfile = users[0]; if (user.country) { user.country = CoreUtils.instance.getCountryName(user.country); } @@ -759,6 +805,22 @@ export class CoreUserProvider { export class CoreUser extends makeSingleton(CoreUserProvider) {} +/** + * Data passed to PROFILE_REFRESHED event. + */ +export type CoreUserProfileRefreshedData = { + courseId: number; // Course the user profile belongs to. + user: CoreUserProfile; // User affected. +}; + +/** + * Data passed to PROFILE_PICTURE_UPDATED event. + */ +export type CoreUserProfilePictureUpdatedData = { + userId: number; // User ID. + picture: string | undefined; // New picture URL. +}; + /** * Basic data of a user. */ @@ -921,6 +983,11 @@ export type CoreUserCourseProfile = CoreUserData & { enrolledcourses?: CoreUserEnrolledCourse[]; // Courses where the user is enrolled. }; +/** + * User data returned by getProfile. + */ +export type CoreUserProfile = (CoreUserBasicData & Partial) | CoreUserCourseProfile; + /** * Params of core_user_update_picture WS. */ diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index bd6df82ff..cc24d2297 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -17,7 +17,7 @@ import { Md5 } from 'ts-md5/dist/md5'; import { CoreApp } from '@services/app'; import { CoreDB } from '@services/db'; -import { CoreEvents } from '@singletons/events'; +import { CoreEvents, CoreEventUserDeletedData } from '@singletons/events'; import { CoreFile } from '@services/file'; import { CoreWS, @@ -588,7 +588,7 @@ export class CoreSite { error.message = Translate.instance.instant('core.lostconnection'); } else if (error.errorcode === 'userdeleted') { // User deleted, trigger event. - CoreEvents.trigger(CoreEvents.USER_DELETED, { params: data }, this.id); + CoreEvents.trigger(CoreEvents.USER_DELETED, { params: data }, this.id); error.message = Translate.instance.instant('core.userdeleted'); throw new CoreWSError(error); diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index f7033acd0..4d45a790b 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -85,11 +85,11 @@ export class CoreEvents { * @param siteId Site where to trigger the event. Undefined won't check the site. * @return Observer to stop listening. */ - static on(eventName: string, callBack: (value: unknown) => void, siteId?: string): CoreEventObserver { + static on(eventName: string, callBack: (value: T) => void, siteId?: string): CoreEventObserver { // If it's a unique event and has been triggered already, call the callBack. // We don't need to create an observer because the event won't be triggered again. if (this.uniqueEvents[eventName]) { - callBack(this.uniqueEvents[eventName].data); + callBack( this.uniqueEvents[eventName].data); // Return a fake observer to prevent errors. return { @@ -103,10 +103,10 @@ export class CoreEvents { if (typeof this.observables[eventName] == 'undefined') { // No observable for this event, create a new one. - this.observables[eventName] = new Subject(); + this.observables[eventName] = new Subject(); } - const subscription = this.observables[eventName].subscribe((value: {siteId?: string; [key: string]: unknown}) => { + const subscription = this.observables[eventName].subscribe((value: T & {siteId?: string}) => { if (!siteId || value.siteId == siteId) { callBack(value); } @@ -132,8 +132,8 @@ export class CoreEvents { * @param siteId Site where to trigger the event. Undefined won't check the site. * @return Observer to stop listening. */ - static onMultiple(eventNames: string[], callBack: (value: unknown) => void, siteId?: string): CoreEventObserver { - const observers = eventNames.map((name) => this.on(name, callBack, siteId)); + static onMultiple(eventNames: string[], callBack: (value: T) => void, siteId?: string): CoreEventObserver { + const observers = eventNames.map((name) => this.on(name, callBack, siteId)); // Create and return a CoreEventObserver. return { @@ -152,11 +152,11 @@ export class CoreEvents { * @param data Data to pass to the observers. * @param siteId Site where to trigger the event. Undefined means no Site. */ - static trigger(eventName: string, data?: unknown, siteId?: string): void { + static trigger(eventName: string, data?: T, siteId?: string): void { this.logger.debug(`Event '${eventName}' triggered.`); if (this.observables[eventName]) { if (siteId) { - data = Object.assign(data || {}, { siteId }); + Object.assign(data || {}, { siteId }); } this.observables[eventName].next(data); } @@ -169,14 +169,14 @@ export class CoreEvents { * @param data Data to pass to the observers. * @param siteId Site where to trigger the event. Undefined means no Site. */ - static triggerUnique(eventName: string, data: unknown, siteId?: string): void { + static triggerUnique(eventName: string, data: T, siteId?: string): void { if (this.uniqueEvents[eventName]) { this.logger.debug(`Unique event '${eventName}' ignored because it was already triggered.`); } else { this.logger.debug(`Unique event '${eventName}' triggered.`); if (siteId) { - data = Object.assign(data || {}, { siteId }); + Object.assign(data || {}, { siteId }); } // Store the data so it can be passed to observers that register from now on. @@ -241,3 +241,11 @@ export type CoreEventCourseStatusChanged = { courseId: number; // Course Id. status: string; }; + +/** + * Data passed to USER_DELETED event. + */ +export type CoreEventUserDeletedData = CoreEventSiteData & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: any; // Params sent to the WS that failed. +};