diff --git a/src/addons/notifications/components/actions/actions.ts b/src/addons/notifications/components/actions/actions.ts new file mode 100644 index 000000000..cd0ceb35b --- /dev/null +++ b/src/addons/notifications/components/actions/actions.ts @@ -0,0 +1,91 @@ +// (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 { Component, Input, OnInit } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreContentLinksDelegate, CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; + +/** + * Component that displays the actions for a notification. + */ +@Component({ + selector: 'addon-notifications-actions', + templateUrl: 'addon-notifications-actions.html', +}) +export class AddonNotificationsActionsComponent implements OnInit { + + @Input() contextUrl?: string; + @Input() courseId?: number; + @Input() data?: Record; // Extra data to handle the URL. + + actions: CoreContentLinksAction[] = []; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!this.contextUrl && (!this.data || !this.data.appurl)) { + // No URL, nothing to do. + return; + } + + let actions: CoreContentLinksAction[] = []; + + // Treat appurl first if any. + if (this.data?.appurl) { + actions = await CoreContentLinksDelegate.instance.getActionsFor( + this.data.appurl, + this.courseId, + undefined, + this.data, + ); + } + + if (!actions.length && this.contextUrl) { + // No appurl or cannot handle it. Try with contextUrl. + actions = await CoreContentLinksDelegate.instance.getActionsFor(this.contextUrl, this.courseId, undefined, this.data); + } + + if (!actions.length) { + // URL is not supported. Add an action to open it in browser. + actions.push({ + message: 'core.view', + icon: 'fas-eye', + action: this.openInBrowser.bind(this), + }); + } + + this.actions = actions; + } + + /** + * Default action. Open in browser. + * + * @param siteId Site ID to use. + * @param navCtrl NavController. + */ + protected async openInBrowser(siteId?: string): Promise { + const url = this.data?.appurl || this.contextUrl; + + if (!url) { + return; + } + + const site = await CoreSites.instance.getSite(siteId); + + site.openInBrowserWithAutoLogin(url); + } + +} diff --git a/src/addons/notifications/components/actions/addon-notifications-actions.html b/src/addons/notifications/components/actions/addon-notifications-actions.html new file mode 100644 index 000000000..1bb8682ca --- /dev/null +++ b/src/addons/notifications/components/actions/addon-notifications-actions.html @@ -0,0 +1,8 @@ + + + + + {{ action.message | translate }} + + + diff --git a/src/addons/notifications/components/components.module.ts b/src/addons/notifications/components/components.module.ts new file mode 100644 index 000000000..4d873ecd1 --- /dev/null +++ b/src/addons/notifications/components/components.module.ts @@ -0,0 +1,35 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AddonNotificationsActionsComponent } from './actions/actions'; + +@NgModule({ + declarations: [ + AddonNotificationsActionsComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + ], + exports: [ + AddonNotificationsActionsComponent, + ], +}) +export class AddonNotificationsComponentsModule {} diff --git a/src/addons/notifications/lang.json b/src/addons/notifications/lang.json new file mode 100644 index 000000000..3844a5525 --- /dev/null +++ b/src/addons/notifications/lang.json @@ -0,0 +1,8 @@ +{ + "errorgetnotifications": "Error getting notifications.", + "markallread": "Mark all as read", + "notificationpreferences": "Notification preferences", + "notifications": "Notifications", + "playsound": "Play sound", + "therearentnotificationsyet": "There are no notifications." +} \ No newline at end of file diff --git a/src/addons/notifications/pages/list/list.html b/src/addons/notifications/pages/list/list.html new file mode 100644 index 000000000..b454f0f24 --- /dev/null +++ b/src/addons/notifications/pages/list/list.html @@ -0,0 +1,65 @@ + + + + + + {{ 'addon.notifications.notifications' | translate }} + + + + + + + +
+ + + {{ 'addon.notifications.markallread' | translate }} + + + + +
+ + + + + + + + +

{{ notification.subject }}

+

{{ notification.userfromfullname }}

+
+ + {{ notification.timecreated | coreDateDayOrTime }} + + + + + +
+ + + + + + + + +
+ + + + + + +
+
diff --git a/src/addons/notifications/pages/list/list.module.ts b/src/addons/notifications/pages/list/list.module.ts new file mode 100644 index 000000000..0171deb9c --- /dev/null +++ b/src/addons/notifications/pages/list/list.module.ts @@ -0,0 +1,47 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonNotificationsComponentsModule } from '../../components/components.module'; +import { AddonNotificationsListPage } from './list'; + +const routes: Routes = [ + { + path: '', + component: AddonNotificationsListPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + // CoreComponentsModule, + AddonNotificationsComponentsModule, + ], + declarations: [ + AddonNotificationsListPage, + ], + exports: [RouterModule], +}) +export class AddonNotificationsListPageModule {} diff --git a/src/addons/notifications/pages/list/list.scss b/src/addons/notifications/pages/list/list.scss new file mode 100644 index 000000000..be91e7a5c --- /dev/null +++ b/src/addons/notifications/pages/list/list.scss @@ -0,0 +1,61 @@ +:host { + .core-notification-icon { + width: 34px; + height: 34px; + margin: 10px !important; + } + + .item core-format-text ::ng-deep { + .forumpost { + border: 1px solid var(--gray-light); + width: 100%; + margin: 0 0 1em 0; + + td { + padding: 10px; + } + + .header { + background-color: var(--gray-lighter); + } + + .picture { + width: auto; + text-align: center; + } + + .subject { + font-weight: 700; + margin-bottom: 1rem; + } + } + + a { + text-decoration: none; + } + + .userpicture { + border-radius: 50%; + } + + .mdl-right { + text-align: end; + a { + display: none; + } + font { + font-size: 0.9em; + } + } + + .commands { + display: none; + } + + hr { + margin-top: 1.5rem; + margin-bottom: 1.5rem; + background-color: var(--gray-light); + } + } +} diff --git a/src/addons/notifications/pages/list/list.ts b/src/addons/notifications/pages/list/list.ts new file mode 100644 index 000000000..b1c7b03e7 --- /dev/null +++ b/src/addons/notifications/pages/list/list.ts @@ -0,0 +1,263 @@ +// (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 { Component, OnDestroy, OnInit } from '@angular/core'; +import { IonRefresher } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEvents, CoreEventObserver } from '@singletons/events'; +import { AddonNotifications, AddonNotificationsAnyNotification, AddonNotificationsProvider } from '../../services/notifications'; +import { AddonNotificationsHelper } from '../../services/notifications-helper'; +import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; + +/** + * Page that displays the list of notifications. + */ +@Component({ + selector: 'page-addon-notifications-list', + templateUrl: 'list.html', + styleUrls: ['list.scss'], +}) +export class AddonNotificationsListPage implements OnInit, OnDestroy { + + notifications: FormattedNotification[] = []; + notificationsLoaded = false; + canLoadMore = false; + loadMoreError = false; + canMarkAllNotificationsAsRead = false; + loadingMarkAllNotificationsAsRead = false; + + protected isCurrentView?: boolean; + protected cronObserver?: CoreEventObserver; + protected pushObserver?: Subscription; + protected pendingRefresh = false; + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.fetchNotifications(); + + this.cronObserver = CoreEvents.on(AddonNotificationsProvider.READ_CRON_EVENT, () => { + if (!this.isCurrentView) { + return; + } + + this.notificationsLoaded = false; + this.refreshNotifications(); + }, CoreSites.instance.getCurrentSiteId()); + + this.pushObserver = CorePushNotificationsDelegate.instance.on('receive').subscribe((notification) => { + // New notification received. If it's from current site, refresh the data. + if (!this.isCurrentView) { + this.pendingRefresh = true; + + return; + } + + if (!CoreUtils.instance.isTrueOrOne(notification.notif) || !CoreSites.instance.isCurrentSite(notification.site)) { + return; + } + + this.notificationsLoaded = false; + this.refreshNotifications(); + }); + } + + /** + * Convenience function to get notifications. Gets unread notifications first. + * + * @param refreh Whether we're refreshing data. + * @return Resolved when done. + */ + protected async fetchNotifications(refresh?: boolean): Promise { + this.loadMoreError = false; + + try { + const result = await AddonNotificationsHelper.instance.getNotifications(refresh ? [] : this.notifications); + + const notifications = result.notifications.map((notification) => this.formatText(notification)); + + if (refresh) { + this.notifications = notifications; + } else { + this.notifications = this.notifications.concat(notifications); + } + this.canLoadMore = result.canLoadMore; + + this.markNotificationsAsRead(notifications); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.notifications.errorgetnotifications', true); + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + } finally { + this.notificationsLoaded = true; + } + } + + /** + * Mark all notifications as read. + * + * @return Promise resolved when done. + */ + async markAllNotificationsAsRead(): Promise { + this.loadingMarkAllNotificationsAsRead = true; + + await CoreUtils.instance.ignoreErrors(AddonNotifications.instance.markAllNotificationsAsRead()); + + CoreEvents.trigger(AddonNotificationsProvider.READ_CHANGED_EVENT, {}, CoreSites.instance.getCurrentSiteId()); + + // All marked as read, refresh the list. + this.notificationsLoaded = false; + + await this.refreshNotifications(); + } + + /** + * Mark notifications as read. + * + * @param notifications Array of notification objects. + */ + protected async markNotificationsAsRead(notifications: FormattedNotification[]): Promise { + if (notifications.length > 0) { + const promises = notifications.map(async (notification) => { + if (notification.read) { + // Already read, don't mark it. + return; + } + + await AddonNotifications.instance.markNotificationRead(notification.id); + }); + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); + + await CoreUtils.instance.ignoreErrors(AddonNotifications.instance.invalidateNotificationsList()); + + CoreEvents.trigger(AddonNotificationsProvider.READ_CHANGED_EVENT, {}, CoreSites.instance.getCurrentSiteId()); + } + + // Check if mark all notifications as read is enabled and there are some to read. + if (!AddonNotifications.instance.isMarkAllNotificationsAsReadEnabled()) { + this.canMarkAllNotificationsAsRead = false; + + return; + } + + try { + this.loadingMarkAllNotificationsAsRead = true; + + const unread = await AddonNotifications.instance.getUnreadNotificationsCount(); + + this.canMarkAllNotificationsAsRead = unread > 0; + } finally { + this.loadingMarkAllNotificationsAsRead = false; + } + } + + /** + * Refresh notifications. + * + * @param refresher Refresher. + * @return Promise Promise resolved when done. + */ + async refreshNotifications(refresher?: CustomEvent): Promise { + await CoreUtils.instance.ignoreErrors(AddonNotifications.instance.invalidateNotificationsList()); + + try { + await this.fetchNotifications(true); + } finally { + refresher?.detail.complete(); + } + } + + /** + * Load more results. + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + */ + async loadMoreNotifications(infiniteComplete?: () => void): Promise { + try { + await this.fetchNotifications(); + } finally { + infiniteComplete?.(); + } + } + + /** + * Formats the text of a notification. + * + * @param notification The notification object. + */ + protected formatText(notification: AddonNotificationsAnyNotification): FormattedNotification { + const formattedNotification: FormattedNotification = notification; + formattedNotification.displayfullhtml = this.shouldDisplayFullHtml(notification); + formattedNotification.iconurl = formattedNotification.iconurl || undefined; // Make sure the property exists. + + formattedNotification.mobiletext = formattedNotification.displayfullhtml ? + notification.fullmessagehtml : + CoreTextUtils.instance.replaceNewLines(formattedNotification.mobiletext!.replace(/-{4,}/ig, ''), '
'); + + return formattedNotification; + } + + /** + * Check whether we should display full HTML of the notification. + * + * @param notification Notification. + * @return Whether to display full HTML. + */ + protected shouldDisplayFullHtml(notification: FormattedNotification): boolean { + return notification.component == 'mod_forum' && notification.eventtype == 'digests'; + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.isCurrentView = true; + + if (!this.pendingRefresh) { + return; + } + + this.pendingRefresh = false; + this.notificationsLoaded = false; + + this.refreshNotifications(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.isCurrentView = false; + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.cronObserver?.off(); + this.pushObserver?.unsubscribe(); + } + +} + +type FormattedNotification = AddonNotificationsAnyNotification & { + displayfullhtml?: boolean; // Whether to display the full HTML of the notification. + iconurl?: string; +}; diff --git a/src/core/features/mainmenu/pages/more/more.html b/src/core/features/mainmenu/pages/more/more.html index 20a7eb21b..7afb0f4cb 100644 --- a/src/core/features/mainmenu/pages/more/more.html +++ b/src/core/features/mainmenu/pages/more/more.html @@ -10,7 +10,7 @@ - +

{{siteInfo.fullname}}

diff --git a/src/core/features/pushnotifications/services/push-delegate.ts b/src/core/features/pushnotifications/services/push-delegate.ts index 2fb972b6d..6a062181f 100644 --- a/src/core/features/pushnotifications/services/push-delegate.ts +++ b/src/core/features/pushnotifications/services/push-delegate.ts @@ -149,7 +149,7 @@ export class CorePushNotificationsDelegateService { * @param eventName Only receive is permitted. * @return Observer to subscribe. */ - on(eventName: string): Subject { + on(eventName: string): Subject { if (typeof this.observables[eventName] == 'undefined') { const eventNames = Object.keys(this.observables).join(', '); this.logger.warn(`'${eventName}' event name is not allowed. Use one of the following: '${eventNames}'.`); diff --git a/src/core/pipes/date-day-or-time.ts b/src/core/pipes/date-day-or-time.ts new file mode 100644 index 000000000..6b8f68714 --- /dev/null +++ b/src/core/pipes/date-day-or-time.ts @@ -0,0 +1,72 @@ +// (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 { Pipe, PipeTransform } from '@angular/core'; +import moment from 'moment'; + +import { CoreTimeUtils } from '@services/utils/time'; +import { Translate } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Filter to display a date using the day, or the time. + * + * This shows a short version of a date. Use this filter when you want + * the user to visualise when the action was done relatively to today's date. + * + * For instance, if the action happened during this day it will display the time, + * but when the action happened few days ago, it will display the day of the week. + * + * The older the date is, the more information about it will be displayed. + * + * This filter expects a timestamp NOT including milliseconds. + */ +@Pipe({ + name: 'coreDateDayOrTime', +}) +export class CoreDateDayOrTimePipe implements PipeTransform { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreDateDayOrTimePipe'); + } + + /** + * Format a timestamp. + * + * @param timestamp The UNIX timestamp (without milliseconds). + * @return Formatted time. + */ + transform(timestamp: string | number): string { + if (typeof timestamp == 'string') { + // Convert the value to a number. + const numberTimestamp = parseInt(timestamp, 10); + if (isNaN(numberTimestamp)) { + this.logger.error('Invalid value received', timestamp); + + return timestamp; + } + timestamp = numberTimestamp; + } + + return moment(timestamp * 1000).calendar(null, { + sameDay: CoreTimeUtils.instance.convertPHPToMoment(Translate.instance.instant('core.strftimetime')), + lastDay: Translate.instance.instant('core.dflastweekdate'), + lastWeek: Translate.instance.instant('core.dflastweekdate'), + sameElse: CoreTimeUtils.instance.convertPHPToMoment(Translate.instance.instant('core.strftimedatefullshort')), + }); + } + +} diff --git a/src/core/pipes/pipes.module.ts b/src/core/pipes/pipes.module.ts index e27839889..8e32caa7f 100644 --- a/src/core/pipes/pipes.module.ts +++ b/src/core/pipes/pipes.module.ts @@ -20,6 +20,7 @@ import { CoreSecondsToHMSPipe } from './seconds-to-hms'; import { CoreTimeAgoPipe } from './time-ago'; import { CoreBytesToSizePipe } from './bytes-to-size'; import { CoreDurationPipe } from './duration'; +import { CoreDateDayOrTimePipe } from './date-day-or-time'; @NgModule({ declarations: [ @@ -30,6 +31,7 @@ import { CoreDurationPipe } from './duration'; CoreBytesToSizePipe, CoreSecondsToHMSPipe, CoreDurationPipe, + CoreDateDayOrTimePipe, ], imports: [], exports: [ @@ -40,6 +42,7 @@ import { CoreDurationPipe } from './duration'; CoreBytesToSizePipe, CoreSecondsToHMSPipe, CoreDurationPipe, + CoreDateDayOrTimePipe, ], }) export class CorePipesModule {} diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index 2c4ee721f..bac5b6d49 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -1016,7 +1016,7 @@ export class CoreSitesProvider { * @param site Site object or siteId to be compared. If not defined, use current site. * @return Whether site or siteId is the current one. */ - isCurrentSite(site: string | CoreSite): boolean { + isCurrentSite(site?: string | CoreSite): boolean { if (!site || !this.currentSite) { return !!this.currentSite; } diff --git a/src/core/shared.module.ts b/src/core/shared.module.ts index 5f7987b34..ab2842e22 100644 --- a/src/core/shared.module.ts +++ b/src/core/shared.module.ts @@ -18,7 +18,7 @@ import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; @NgModule({ - imports: [ + exports: [ CoreComponentsModule, CoreDirectivesModule, CorePipesModule,