diff --git a/src/addons/notifications/services/notifications-helper.ts b/src/addons/notifications/services/notifications-helper.ts new file mode 100644 index 000000000..bcae25801 --- /dev/null +++ b/src/addons/notifications/services/notifications-helper.ts @@ -0,0 +1,87 @@ +// (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 { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons'; +import { + AddonNotifications, + AddonNotificationsAnyNotification, + AddonNotificationsGetNotificationsOptions, + AddonNotificationsProvider, +} from './notifications'; + +/** + * Service that provides some helper functions for notifications. + */ +@Injectable({ providedIn: 'root' }) +export class AddonNotificationsHelperProvider { + + /** + * Get some notifications. It will try to use the new WS if available. + * + * @param notifications Current list of loaded notifications. It's used to calculate the offset. + * @param options Other options. + * @return Promise resolved with notifications and if can load more. + */ + async getNotifications( + notifications: AddonNotificationsAnyNotification[], + options?: AddonNotificationsGetNotificationsOptions, + ): Promise<{notifications: AddonNotificationsAnyNotification[]; canLoadMore: boolean}> { + + notifications = notifications || []; + options = options || {}; + options.limit = options.limit || AddonNotificationsProvider.LIST_LIMIT; + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + const available = await AddonNotifications.instance.isPopupAvailable(options.siteId); + + if (available) { + return AddonNotifications.instance.getPopupNotifications(notifications.length, options); + } + + // Fallback to get_messages. We need 2 calls, one for read and the other one for unread. + const unreadFrom = notifications.reduce((total, current) => total + (current.read ? 0 : 1), 0); + + const unread = await AddonNotifications.instance.getUnreadNotifications(unreadFrom, options); + + let newNotifications = unread; + + if (unread.length < options.limit) { + // Limit not reached. Get read notifications until reach the limit. + const readLimit = options.limit - unread.length; + const readFrom = notifications.length - unreadFrom; + const readOptions = Object.assign({}, options, { limit: readLimit }); + + try { + const read = await AddonNotifications.instance.getReadNotifications(readFrom, readOptions); + + newNotifications = unread.concat(read); + } catch (error) { + if (unread.length <= 0) { + throw error; + } + } + } + + return { + notifications: newNotifications, + canLoadMore: notifications.length >= options.limit, + }; + } + +} + +export class AddonNotificationsHelper extends makeSingleton(AddonNotificationsHelperProvider) {} diff --git a/src/addons/notifications/services/notifications.ts b/src/addons/notifications/services/notifications.ts new file mode 100644 index 000000000..987259289 --- /dev/null +++ b/src/addons/notifications/services/notifications.ts @@ -0,0 +1,658 @@ +// (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 { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; +import { CoreWSExternalWarning } from '@services/ws'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUser } from '@features/user/services/user'; +// @todo import { AddonMessages, AddonMessagesMarkMessageReadResult } from '@addon/messages/services/messages'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreLogger } from '@singletons/logger'; +import { makeSingleton } from '@singletons'; + +const ROOT_CACHE_KEY = 'mmaNotifications:'; + +/** + * Service to handle notifications. + */ +@Injectable({ providedIn: 'root' }) +export class AddonNotificationsProvider { + + static readonly READ_CHANGED_EVENT = 'addon_notifications_read_changed_event'; + static readonly READ_CRON_EVENT = 'addon_notifications_read_cron_event'; + static readonly PUSH_SIMULATION_COMPONENT = 'AddonNotificationsPushSimulation'; + static readonly LIST_LIMIT = 20; + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('AddonNotificationsProvider'); + } + + /** + * Function to format notification data. + * + * @param notifications List of notifications. + * @param read Whether the notifications are read or unread. + * @return Promise resolved with notifications. + */ + protected async formatNotificationsData( + notifications: AddonNotificationsGetMessagesMessage[], + read?: boolean, + ): Promise; + protected async formatNotificationsData( + notifications: AddonNotificationsPopupNotification[], + read?: boolean, + ): Promise; + protected async formatNotificationsData( + notifications: (AddonNotificationsGetMessagesMessage | AddonNotificationsPopupNotification)[], + read?: boolean, + ): Promise { + + const promises = notifications.map(async (notificationRaw) => { + const notification = notificationRaw; + + // Set message to show. + if (notification.component && notification.component == 'mod_forum') { + notification.mobiletext = notification.smallmessage; + } else { + notification.mobiletext = notification.fullmessage; + } + + notification.moodlecomponent = notification.component; + notification.notification = 1; + notification.notif = 1; + if (typeof read != 'undefined') { + notification.read = read; + } + + if (typeof notification.customdata == 'string') { + notification.customdata = CoreTextUtils.instance.parseJSON>(notification.customdata, {}); + } + + // Try to set courseid the notification belongs to. + if (notification.customdata?.courseid) { + notification.courseid = notification.customdata.courseid; + } else if (!notification.courseid) { + const courseIdMatch = notification.fullmessagehtml.match(/course\/view\.php\?id=([^"]*)/); + if (courseIdMatch?.[1]) { + notification.courseid = parseInt(courseIdMatch[1], 10); + } + } + + if (notification.useridfrom > 0) { + // Try to get the profile picture of the user. + try { + const user = await CoreUser.instance.getProfile(notification.useridfrom, notification.courseid, true); + + notification.profileimageurlfrom = user.profileimageurl; + notification.userfromfullname = user.fullname; + } catch { + // Error getting user. This can happen if device is offline or the user is deleted. + } + } + + return notification; + }); + + return Promise.all(promises); + } + + /** + * Get the cache key for the get notification preferences call. + * + * @return Cache key. + */ + protected getNotificationPreferencesCacheKey(): string { + return ROOT_CACHE_KEY + 'notificationPreferences'; + } + + /** + * Get notification preferences. + * + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the notification preferences. + */ + async getNotificationPreferences(siteId?: string): Promise { + this.logger.debug('Get notification preferences'); + + const site = await CoreSites.instance.getSite(siteId); + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getNotificationPreferencesCacheKey(), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + const data = await site.read( + 'core_message_get_user_notification_preferences', + {}, + preSets, + ); + + return data.preferences; + } + + /** + * Get cache key for notification list WS calls. + * + * @return Cache key. + */ + protected getNotificationsCacheKey(): string { + return ROOT_CACHE_KEY + 'list'; + } + + /** + * Get notifications from site. + * + * @param read True if should get read notifications, false otherwise. + * @param offset Position of the first notification to get. + * @param options Other options. + * @return Promise resolved with notifications. + */ + async getNotifications( + read: boolean, + offset: number, + options?: AddonNotificationsGetNotificationsOptions, + ): Promise { + options = options || {}; + options.limit = options.limit || AddonNotificationsProvider.LIST_LIMIT; + + this.logger.debug(`Get ${(read ? 'read' : 'unread')} notifications from ${offset}. Limit: ${options.limit}`); + + const site = await CoreSites.instance.getSite(options.siteId); + const data: AddonNotificationsGetMessagesWSParams = { + useridto: site.getUserId(), + useridfrom: 0, + type: 'notifications', + read: !!read, + newestfirst: true, + limitfrom: offset, + limitnum: options.limit, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getNotificationsCacheKey(), + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + // Get unread notifications. + const response = await site.read('core_message_get_messages', data, preSets); + + const notifications = response.messages; + + return this.formatNotificationsData(notifications, read); + } + + /** + * Get notifications from site using the new WebService. + * + * @param offset Position of the first notification to get. + * @param options Other options. + * @return Promise resolved with notifications and if can load more. + * @since 3.2 + */ + async getPopupNotifications( + offset: number, + options?: AddonNotificationsGetNotificationsOptions, + ): Promise<{notifications: AddonNotificationsPopupNotificationFormatted[]; canLoadMore: boolean}> { + options = options || {}; + options.limit = options.limit || AddonNotificationsProvider.LIST_LIMIT; + + this.logger.debug(`Get popup notifications from ${offset}. Limit: ${options.limit}`); + + const site = await CoreSites.instance.getSite(options.siteId); + const data: AddonNotificationsPopupGetPopupNotificationsWSParams = { + useridto: site.getUserId(), + newestfirst: true, + offset, + limit: options.limit + 1, // Get one more to calculate canLoadMore. + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getNotificationsCacheKey(), + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + // Get notifications. + const response = await site.read( + 'message_popup_get_popup_notifications', + data, + preSets, + ); + + const notifications = await this.formatNotificationsData(response.notifications.slice(0, options.limit)); + + return { + canLoadMore: response.notifications.length > options.limit, + notifications, + }; + } + + /** + * Get read notifications from site. + * + * @param offset Position of the first notification to get. + * @param options Other options. + * @return Promise resolved with notifications. + */ + getReadNotifications( + offset: number, + options?: AddonNotificationsGetNotificationsOptions, + ): Promise { + return this.getNotifications(true, offset, options); + } + + /** + * Get unread notifications from site. + * + * @param offset Position of the first notification to get. + * @param options Other options. + * @return Promise resolved with notifications. + */ + getUnreadNotifications( + offset: number, + options?: AddonNotificationsGetNotificationsOptions, + ): Promise { + return this.getNotifications(false, offset, options); + } + + /** + * Get unread notifications count. Do not cache calls. + * + * @param userId The user id who received the notification. If not defined, use current user. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the message notifications count. + */ + async getUnreadNotificationsCount(userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // @since 3.2 + if (site.wsAvailable('message_popup_get_unread_popup_notification_count')) { + userId = userId || site.getUserId(); + const params: AddonNotificationsPopupGetUnreadPopupNotificationCountWSParams = { + useridto: userId, + }; + const preSets: CoreSiteWSPreSets = { + getFromCache: false, + emergencyCache: false, + saveToCache: false, + typeExpected: 'number', + }; + + try { + return await site.read('message_popup_get_unread_popup_notification_count', params, preSets); + } catch { + // Return no messages if the call fails. + return 0; + } + } + + // Fallback call + try { + const unread = await this.getUnreadNotifications(0, { limit: AddonNotificationsProvider.LIST_LIMIT, siteId }); + + // The app used to add a + sign if needed, but 3.1 will be dropped soon so it's easier to always return a number. + return unread.length; + } catch { + // Return no messages if the call fails. + return 0; + } + } + + /** + * Returns whether or not popup WS is available for a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if available, resolved with false or rejected otherwise. + * @since 3.2 + */ + async isPopupAvailable(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.wsAvailable('message_popup_get_popup_notifications'); + } + + /** + * Mark all message notification as read. + * + * @return Resolved when done. + * @since 3.2 + */ + async markAllNotificationsAsRead(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreMessageMarkAllNotificationsAsReadWSParams = { + useridto: CoreSites.instance.getCurrentSiteUserId(), + }; + + return site.write('core_message_mark_all_notifications_as_read', params); + } + + /** + * Mark a single notification as read. + * + * @param notificationId ID of notification to mark as read + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + * @since 3.5 + */ + async markNotificationRead( + notificationId: number, + siteId?: string, + ): Promise { // @todo | AddonMessagesMarkMessageReadResult + + const site = await CoreSites.instance.getSite(siteId); + + if (site.wsAvailable('core_message_mark_notification_read')) { + const params: CoreMessageMarkNotificationReadWSParams = { + notificationid: notificationId, + timeread: CoreTimeUtils.instance.timestamp(), + }; + + return site.write('core_message_mark_notification_read', params); + } else { + // Fallback for versions prior to 3.5. + // @todo return AddonMessageProvider.instance.markMessageRead(notificationId, site.id); + } + } + + /** + * Invalidate get notification preferences. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data is invalidated. + */ + async invalidateNotificationPreferences(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getNotificationPreferencesCacheKey()); + } + + /** + * Invalidates notifications list WS calls. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the list is invalidated. + */ + async invalidateNotificationsList(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getNotificationsCacheKey()); + } + + /** + * Returns whether or not we can mark all notifications as read. + * + * @return True if enabled, false otherwise. + * @since 3.2 + */ + isMarkAllNotificationsAsReadEnabled(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_message_mark_all_notifications_as_read'); + } + + /** + * Returns whether or not we can count unread notifications precisely. + * + * @return True if enabled, false otherwise. + * @since 3.2 + */ + isPreciseNotificationCountEnabled(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('message_popup_get_unread_popup_notification_count'); + } + + /** + * Returns whether or not the notification preferences are enabled for the current site. + * + * @return True if enabled, false otherwise. + * @since 3.2 + */ + isNotificationPreferencesEnabled(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_message_get_user_notification_preferences'); + } + +} + +export class AddonNotifications extends makeSingleton(AddonNotificationsProvider) {} + +/** + * Preferences returned by core_message_get_user_notification_preferences. + */ +export type AddonNotificationsPreferences = { + userid: number; // User id. + disableall: number | boolean; // Whether all the preferences are disabled. + processors: AddonNotificationsPreferencesProcessor[]; // Config form values. + components: AddonNotificationsPreferencesComponent[]; // Available components. + enableall?: boolean; // Calculated in the app. Whether all the preferences are enabled. +}; + +/** + * Processor in notification preferences. + */ +export type AddonNotificationsPreferencesProcessor = { + displayname: string; // Display name. + name: string; // Processor name. + hassettings: boolean; // Whether has settings. + contextid: number; // Context id. + userconfigured: number; // Whether is configured by the user. +}; + +/** + * Component in notification preferences. + */ +export type AddonNotificationsPreferencesComponent = { + displayname: string; // Display name. + notifications: AddonNotificationsPreferencesNotification[]; // List of notificaitons for the component. +}; + +/** + * Notification processor in notification preferences component. + */ +export type AddonNotificationsPreferencesNotification = { + displayname: string; // Display name. + preferencekey: string; // Preference key. + processors: AddonNotificationsPreferencesNotificationProcessor[]; // Processors values for this notification. +}; + +/** + * Notification processor in notification preferences component. + */ +export type AddonNotificationsPreferencesNotificationProcessor = { + displayname: string; // Display name. + name: string; // Processor name. + locked: boolean; // Is locked by admin?. + lockedmessage?: string; // @since 3.6. Text to display if locked. + userconfigured: number; // Is configured?. + loggedin: AddonNotificationsPreferencesNotificationProcessorState; + loggedoff: AddonNotificationsPreferencesNotificationProcessorState; +}; + +/** + * State in notification processor in notification preferences component. + */ +export type AddonNotificationsPreferencesNotificationProcessorState = { + name: string; // Name. + displayname: string; // Display name. + checked: boolean; // Is checked?. +}; + +/** + * Params of core_message_get_messages WS. + */ +export type AddonNotificationsGetMessagesWSParams = { + useridto: number; // The user id who received the message, 0 for any user. + useridfrom?: number; // The user id who send the message, 0 for any user. -10 or -20 for no-reply or support user. + type?: string; // Type of message to return, expected values are: notifications, conversations and both. + read?: boolean; // True for getting read messages, false for unread. + newestfirst?: boolean; // True for ordering by newest first, false for oldest first. + limitfrom?: number; // Limit from. + limitnum?: number; // Limit number. +}; + +/** + * Data returned by core_message_get_messages WS. + */ +export type AddonNotificationsGetMessagesWSResponse = { + messages: AddonNotificationsGetMessagesMessage[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Message data returned by core_message_get_messages. + */ +export type AddonNotificationsGetMessagesMessage = { + id: number; // Message id. + useridfrom: number; // User from id. + useridto: number; // User to id. + subject: string; // The message subject. + text: string; // The message text formated. + fullmessage: string; // The message. + fullmessageformat: number; // Fullmessage format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + fullmessagehtml: string; // The message in html. + smallmessage: string; // The shorten message. + notification: number; // Is a notification?. + contexturl: string; // Context URL. + contexturlname: string; // Context URL link name. + timecreated: number; // Time created. + timeread: number; // Time read. + usertofullname: string; // User to full name. + userfromfullname: string; // User from full name. + component?: string; // @since 3.7. The component that generated the notification. + eventtype?: string; // @since 3.7. The type of notification. + customdata?: string; // @since 3.7. Custom data to be passed to the message processor. +}; + +/** + * Message data returned by core_message_get_messages with some calculated data. + */ +export type AddonNotificationsGetMessagesMessageFormatted = + Omit & AddonNotificationsNotificationCalculatedData; + +/** + * Params of message_popup_get_popup_notifications WS. + */ +export type AddonNotificationsPopupGetPopupNotificationsWSParams = { + useridto: number; // The user id who received the message, 0 for current user. + newestfirst?: boolean; // True for ordering by newest first, false for oldest first. + limit?: number; // The number of results to return. + offset?: number; // Offset the result set by a given amount. +}; + +/** + * Result of WS message_popup_get_popup_notifications. + */ +export type AddonNotificationsGetPopupNotificationsResult = { + notifications: AddonNotificationsPopupNotification[]; + unreadcount: number; // The number of unread message for the given user. +}; + +/** + * Notification returned by message_popup_get_popup_notifications. + */ +export type AddonNotificationsPopupNotification = { + id: number; // Notification id (this is not guaranteed to be unique within this result set). + useridfrom: number; // User from id. + useridto: number; // User to id. + subject: string; // The notification subject. + shortenedsubject: string; // The notification subject shortened with ellipsis. + text: string; // The message text formated. + fullmessage: string; // The message. + fullmessageformat: number; // Fullmessage format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + fullmessagehtml: string; // The message in html. + smallmessage: string; // The shorten message. + contexturl: string; // Context URL. + contexturlname: string; // Context URL link name. + timecreated: number; // Time created. + timecreatedpretty: string; // Time created in a pretty format. + timeread: number; // Time read. + read: boolean; // Notification read status. + deleted: boolean; // Notification deletion status. + iconurl: string; // URL for notification icon. + component?: string; // The component that generated the notification. + eventtype?: string; // The type of notification. + customdata?: string; // @since 3.7. Custom data to be passed to the message processor. +}; + +/** + * Notification returned by message_popup_get_popup_notifications. + */ +export type AddonNotificationsPopupNotificationFormatted = + Omit & AddonNotificationsNotificationCalculatedData; + +/** + * Any kind of notification that can be retrieved. + */ +export type AddonNotificationsAnyNotification = + AddonNotificationsPopupNotificationFormatted | AddonNotificationsGetMessagesMessageFormatted; + +/** + * Result of WS core_message_get_user_notification_preferences. + */ +export type AddonNotificationsGetUserNotificationPreferencesResult = { + preferences: AddonNotificationsPreferences; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Calculated data for messages returned by core_message_get_messages. + */ +export type AddonNotificationsNotificationCalculatedData = { + mobiletext?: string; // Calculated in the app. Text to display for the notification. + moodlecomponent?: string; // Calculated in the app. Moodle's component. + notif?: number; // Calculated in the app. Whether it's a notification. + notification?: number; // Calculated in the app in some cases. Whether it's a notification. + read?: boolean; // Calculated in the app. Whether the notifications is read. + courseid?: number; // Calculated in the app. Course the notification belongs to. + profileimageurlfrom?: string; // Calculated in the app. Avatar of user that sent the notification. + userfromfullname?: string; // Calculated in the app in some cases. User from full name. + customdata?: Record; // Parsed custom data. +}; + +/** + * Params of message_popup_get_unread_popup_notification_count WS. + */ +export type AddonNotificationsPopupGetUnreadPopupNotificationCountWSParams = { + useridto: number; // The user id who received the message, 0 for any user. +}; + +/** + * Params of core_message_mark_all_notifications_as_read WS. + */ +export type CoreMessageMarkAllNotificationsAsReadWSParams = { + useridto: number; // The user id who received the message, 0 for any user. + useridfrom?: number; // The user id who send the message, 0 for any user. -10 or -20 for no-reply or support user. + timecreatedto?: number; // Mark messages created before this time as read, 0 for all messages. +}; + +/** + * Params of core_message_mark_notification_read WS. + */ +export type CoreMessageMarkNotificationReadWSParams = { + notificationid: number; // Id of the notification. + timeread?: number; // Timestamp for when the notification should be marked read. +}; + +/** + * Data returned by core_message_mark_notification_read WS. + */ +export type CoreMessageMarkNotificationReadWSResponse = { + notificationid: number; // Id of the notification. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Options to pass to getNotifications and getPopupNotifications. + */ +export type AddonNotificationsGetNotificationsOptions = CoreSitesCommonWSOptions & { + limit?: number; // Number of notifications to get. Defaults to LIST_LIMIT. +}; diff --git a/upgrade.txt b/upgrade.txt index b465a7f51..0f6b475e9 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -1,9 +1,14 @@ This files describes API changes in the Moodle Mobile app, information provided here is intended especially for developers. +=== 3.9.5 === + +- Several functions inside AddonNotificationsProvider have been modified to accept an "options" parameter instead of having several optional parameters. + === 3.9.3 === - In the core-attachments component, passing a -1 as maxSize or maxSubmissions used to mean "unknown limit". Now -1 means unlimited. Also, passing a 0 to maxSize used to mean "unknown" too, now 0 means user max size. +- Most functions that call a WebService in addons/mod have been modified to accept an "options" parameter instead of having several optional parameters. === 3.8.3 ===