MOBILE-4021 notification: add split-view

main
Alfonso Salces 2022-04-21 09:56:47 +02:00
parent b67d27fc0e
commit fa4ad175cc
6 changed files with 241 additions and 151 deletions

View File

@ -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 { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source';
import { AddonNotifications } from '../services/notifications';
import { AddonNotificationsHelper, AddonNotificationsNotificationToRender } from '../services/notifications-helper';
/**
* Provides a list of notifications
*/
export class AddonsNotificationsNotificationsSource extends CoreRoutedItemsManagerSource<AddonNotificationsNotificationToRender> {
/**
* @inheritdoc
*/
protected async loadPageItems(page: number): Promise<{
items: AddonNotificationsNotificationToRender[];
hasMoreItems: boolean;
}> {
// TODO this should be refactored to avoid using the existing items.
const { notifications, canLoadMore } = await AddonNotifications.getNotifications(page === 0 ? [] : this.getItems() ?? []);
return {
items: notifications.map(notification => AddonNotificationsHelper.formatNotificationText(notification)),
hasMoreItems: canLoadMore,
};
}
/**
* @inheritdoc
*/
getItemPath(notification: AddonNotificationsNotificationToRender): string {
return notification.id.toString();
}
}

View File

@ -12,10 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { conditionalRoutes } from '@/app/app-routing.module';
import { Injector, NgModule } from '@angular/core';
import { RouterModule, ROUTES, Routes } from '@angular/router';
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CoreScreen } from '@services/screen';
import { AddonNotificationsMainMenuHandlerService } from './services/handlers/mainmenu';
function buildRoutes(injector: Injector): Routes {
@ -27,6 +29,13 @@ function buildRoutes(injector: Injector): Routes {
},
loadChildren: () => import('./pages/list/list.module').then(m => m.AddonNotificationsListPageModule),
},
...conditionalRoutes([
{
path: 'list/:id',
loadChildren: () => import('./pages/notification/notification.module')
.then(m => m.AddonNotificationsNotificationPageModule),
},
], () => CoreScreen.isMobile),
{
path: 'notification',
loadChildren: () => import('./pages/notification/notification.module')

View File

@ -11,66 +11,72 @@
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!notificationsLoaded" (ionRefresh)="refreshNotifications($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="notificationsLoaded">
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!notifications.loaded" (ionRefresh)="refreshNotifications($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="notifications.loaded">
<ion-item *ngFor="let notification of notifications" class="ion-text-wrap" [attr.aria-label]="
notification.timeread
? notification.subject
: 'addon.notifications.unreadnotification' | translate: {$a: notification.subject}"
(click)="openNotification(notification)" button [detail]="false" lines="full">
<core-user-avatar *ngIf="notification.useridfrom > 0" [user]="notification" slot="start"
[profileUrl]="notification.profileimageurlfrom" [fullname]="notification.userfromfullname"
[userId]="notification.useridfrom">
<div class="core-avatar-extra-img" *ngIf="notification.iconurl && !notification.modname">
<img [src]="notification.iconurl" alt="" role="presentation">
</div>
<core-mod-icon *ngIf="notification.modname" [modicon]="notification.iconurl" [modname]="notification.modname"
[showAlt]="false">
</core-mod-icon>
</core-user-avatar>
<ion-item *ngFor="let notification of notifications.items" class="ion-text-wrap"
[attr.aria-current]="notifications.getItemAriaCurrent(notification)" [attr.aria-label]="
notification.timeread
? notification.subject
: 'addon.notifications.unreadnotification' | translate: {$a: notification.subject}"
(click)="notifications.select(notification)" button [detail]="false" lines="full">
<ng-container *ngIf="notification.useridfrom <= 0 && notification.iconurl">
<div class="core-notification-icon" *ngIf="!notification.modname" slot="start">
<img [src]="notification.iconurl" alt="" role="presentation">
</div>
<core-mod-icon *ngIf="notification.modname" [modicon]="notification.iconurl" [modname]="notification.modname"
[showAlt]="false" class="core-notification-icon" slot="start">
</core-mod-icon>
</ng-container>
<core-user-avatar *ngIf="notification.useridfrom > 0" [user]="notification" slot="start"
[profileUrl]="notification.profileimageurlfrom" [fullname]="notification.userfromfullname"
[userId]="notification.useridfrom">
<div class="core-avatar-extra-img" *ngIf="notification.iconurl && !notification.modname">
<img [src]="notification.iconurl" alt="" role="presentation">
</div>
<core-mod-icon *ngIf="notification.modname" [modicon]="notification.iconurl" [modname]="notification.modname"
[showAlt]="false">
</core-mod-icon>
</core-user-avatar>
<ion-label>
<p class="item-heading">
<core-format-text [text]="notification.subject" contextLevel="system" [contextInstanceId]="0" [wsNotFiltered]="true">
</core-format-text>
</p>
<p>{{ notification.timecreated | coreTimeAgo }}<ng-container *ngIf="notification.useridfrom > 0"> · {{
notification.userfromfullname }}</ng-container>
</p>
</ion-label>
<ion-note slot="end" *ngIf="!notification.timeread">
<ion-icon name="fas-circle" color="primary" aria-hidden="true"></ion-icon>
</ion-note>
</ion-item>
<ng-container *ngIf="notification.useridfrom <= 0 && notification.iconurl">
<div class="core-notification-icon" *ngIf="!notification.modname" slot="start">
<img [src]="notification.iconurl" alt="" role="presentation">
</div>
<core-mod-icon *ngIf="notification.modname" [modicon]="notification.iconurl" [modname]="notification.modname"
[showAlt]="false" class="core-notification-icon" slot="start">
</core-mod-icon>
</ng-container>
<core-empty-box *ngIf="!notifications || notifications.length <= 0" icon="far-bell"
[message]="'addon.notifications.therearentnotificationsyet' | translate">
</core-empty-box>
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMoreNotifications($event)" [error]="loadMoreError">
</core-infinite-loading>
</core-loading>
<ion-label>
<p class="item-heading">
<core-format-text [text]="notification.subject" contextLevel="system" [contextInstanceId]="0"
[wsNotFiltered]="true">
</core-format-text>
</p>
<p>{{ notification.timecreated | coreTimeAgo }}<ng-container *ngIf="notification.useridfrom > 0"> · {{
notification.userfromfullname }}</ng-container>
</p>
</ion-label>
<ion-note slot="end" *ngIf="!notification.timeread">
<ion-icon name="fas-circle" color="primary" aria-hidden="true"></ion-icon>
</ion-note>
</ion-item>
<core-empty-box *ngIf="!notifications || notifications.empty" icon="far-bell"
[message]="'addon.notifications.therearentnotificationsyet' | translate">
</core-empty-box>
<core-infinite-loading [enabled]="notifications.loaded && !notifications.completed" (action)="fetchMoreNotifications($event)"
[error]="fetchMoreNotificationsFailed">
</core-infinite-loading>
</core-loading>
<div class="mark-all-as-read" slot="fixed" collapsible-footer appearOnBottom>
<ion-chip *ngIf="notificationsLoaded && canMarkAllNotificationsAsRead" [disabled]="loadingMarkAllNotificationsAsRead"
color="primary" (click)="markAllNotificationsAsRead()">
<ion-icon name="fas-eye" aria-hidden="true" *ngIf="!loadingMarkAllNotificationsAsRead"></ion-icon>
<ion-spinner [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead">
</ion-spinner>
{{ 'addon.notifications.markallread' | translate }}
</ion-chip>
</div>
<div class="mark-all-as-read" slot="fixed" collapsible-footer appearOnBottom>
<ion-chip *ngIf="notifications.loaded && canMarkAllNotificationsAsRead" [disabled]="loadingMarkAllNotificationsAsRead"
color="primary" (click)="markAllNotificationsAsRead()">
<ion-icon name="fas-eye" aria-hidden="true" *ngIf="!loadingMarkAllNotificationsAsRead"></ion-icon>
<ion-spinner [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead">
</ion-spinner>
{{ 'addon.notifications.markallread' | translate }}
</ion-chip>
</div>
</core-split-view>
</ion-content>

View File

@ -18,11 +18,20 @@ import { RouterModule, Routes } from '@angular/router';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonNotificationsListPage } from './list';
import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module';
import { conditionalRoutes } from '@/app/app-routing.module';
import { CoreScreen } from '@services/screen';
const routes: Routes = [
{
path: '',
component: AddonNotificationsListPage,
children: conditionalRoutes([
{
path: ':id',
loadChildren: () => import('../../pages/notification/notification.module')
.then(m => m.AddonNotificationsNotificationPageModule),
},
], () => CoreScreen.isTablet),
},
];

View File

@ -12,26 +12,26 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnDestroy, OnInit } from '@angular/core';
import { AfterViewInit, Component, OnDestroy, ViewChild } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { Subscription } from 'rxjs';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreEvents, CoreEventObserver } from '@singletons/events';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import {
AddonNotifications,
AddonNotificationsProvider,
AddonNotifications, AddonNotificationsProvider,
} from '../../services/notifications';
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
import {
AddonNotificationsHelper,
AddonNotificationsNotificationToRender,
} from '@addons/notifications/services/notifications-helper';
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
import { CoreNavigator } from '@services/navigator';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
import { CoreSites } from '@services/sites';
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
import { CoreTimeUtils } from '@services/utils/time';
import { AddonsNotificationsNotificationsSource } from '@addons/notifications/classes/notifications-source';
import { CoreListItemsManager } from '@classes/items-management/list-items-manager';
import { AddonNotificationsNotificationToRender } from '@addons/notifications/services/notifications-helper';
/**
* Page that displays the list of notifications.
@ -41,12 +41,11 @@ import { CoreTimeUtils } from '@services/utils/time';
templateUrl: 'list.html',
styleUrls: ['list.scss', '../../notifications.scss'],
})
export class AddonNotificationsListPage implements OnInit, OnDestroy {
export class AddonNotificationsListPage implements AfterViewInit, OnDestroy {
notifications: AddonNotificationsNotificationToRender[] = [];
notificationsLoaded = false;
canLoadMore = false;
loadMoreError = false;
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
notifications!: CoreListItemsManager<AddonNotificationsNotificationToRender, AddonsNotificationsNotificationsSource>;
fetchMoreNotificationsFailed = false;
canMarkAllNotificationsAsRead = false;
loadingMarkAllNotificationsAsRead = false;
@ -56,18 +55,33 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
protected pushObserver?: Subscription;
protected pendingRefresh = false;
constructor() {
try {
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
AddonsNotificationsNotificationsSource,
[],
);
this.notifications = new CoreListItemsManager(source, AddonNotificationsListPage);
} catch(error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
}
}
/**
* @inheritdoc
*/
ngOnInit(): void {
this.fetchNotifications();
async ngAfterViewInit(): Promise<void> {
await this.fetchInitialNotifications();
this.notifications.start(this.splitView);
this.cronObserver = CoreEvents.on(AddonNotificationsProvider.READ_CRON_EVENT, () => {
if (!this.isCurrentView) {
return;
}
this.notificationsLoaded = false;
this.refreshNotifications();
}, CoreSites.getCurrentSiteId());
@ -83,7 +97,6 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
return;
}
this.notificationsLoaded = false;
this.refreshNotifications();
});
@ -92,7 +105,7 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
return;
}
const notification = this.notifications.find((notification) => notification.id === data.id);
const notification = this.notifications.items.find((notification) => notification.id === data.id);
if (!notification) {
return;
}
@ -109,34 +122,47 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
/**
* Convenience function to get notifications. Gets unread notifications first.
*
* @param refresh Whether we're refreshing data.
* @return Resolved when done.
* @param reload Whether to reload the list or load the next page.
*/
protected async fetchNotifications(refresh?: boolean): Promise<void> {
this.loadMoreError = false;
protected async fetchNotifications(reload: boolean): Promise<void> {
reload
? await this.notifications.reload()
: await this.notifications.load();
this.fetchMoreNotificationsFailed = false;
this.loadMarkAllAsReadButton();
}
/**
* Obtain the initial batch of notifications.
*/
private async fetchInitialNotifications(): Promise<void> {
try {
const result = await AddonNotifications.getNotifications(refresh ? [] : this.notifications);
const notifications = result.notifications
.map((notification) => AddonNotificationsHelper.formatNotificationText(notification));
if (refresh) {
this.notifications = notifications;
} else {
this.notifications = this.notifications.concat(notifications);
}
this.canLoadMore = result.canLoadMore;
await this.loadMarkAllAsReadButton();
await this.fetchNotifications(true);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.notifications.errorgetnotifications', true);
this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading.
} finally {
this.notificationsLoaded = true;
CoreDomUtils.showErrorModalDefault(error, 'Error loading notifications');
this.notifications.reset();
}
}
/**
* Load a new batch of Notifications.
*
* @param complete Completion callback.
*/
async fetchMoreNotifications(complete: () => void): Promise<void> {
try {
await this.fetchNotifications(false);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error loading more notifications');
this.fetchMoreNotificationsFailed = true;
}
complete();
}
/**
* Mark all notifications as read.
*
@ -151,9 +177,6 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
time: CoreTimeUtils.timestamp(),
}, CoreSites.getCurrentSiteId());
// All marked as read, refresh the list.
this.notificationsLoaded = false;
await this.refreshNotifications();
}
@ -179,38 +202,12 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
* Refresh notifications.
*
* @param refresher Refresher.
* @return Promise<any> Promise resolved when done.
*/
async refreshNotifications(refresher?: IonRefresher): Promise<void> {
await CoreUtils.ignoreErrors(AddonNotifications.invalidateNotificationsList());
await CoreUtils.ignoreErrors(this.fetchNotifications(true));
try {
await this.fetchNotifications(true);
} finally {
refresher?.complete();
}
}
/**
* Load more results.
*
* @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading.
*/
async loadMoreNotifications(infiniteComplete?: () => void): Promise<void> {
try {
await this.fetchNotifications();
} finally {
infiniteComplete?.();
}
}
/**
* Open Notification page.
*
* @param notification Notification to open.
*/
openNotification(notification: AddonNotificationsNotificationToRender): void {
CoreNavigator.navigate('../notification', { params: { notification } });
refresher?.complete();
}
/**
@ -224,7 +221,6 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
}
this.pendingRefresh = false;
this.notificationsLoaded = false;
this.refreshNotifications();
}
@ -243,6 +239,7 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
this.cronObserver?.off();
this.readObserver?.off();
this.pushObserver?.unsubscribe();
this.notifications?.destroy();
}
}

View File

@ -12,18 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { AddonsNotificationsNotificationsSource } from '@addons/notifications/classes/notifications-source';
import { AddonNotificationsNotificationData } from '@addons/notifications/services/handlers/push-click';
import { AddonNotifications } from '@addons/notifications/services/notifications';
import {
AddonNotificationsHelper,
AddonNotificationsNotificationToRender,
} from '@addons/notifications/services/notifications-helper';
import { Component, OnInit } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
import { CoreContentLinksAction, CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
/**
* Page to render a notification.
@ -58,28 +59,14 @@ export class AddonNotificationsNotificationPage implements OnInit {
let notification: AddonNotificationsNotification;
try {
notification = CoreNavigator.getRequiredRouteParam('notification');
notification = this.getCurrentNotification();
} catch (error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
await CoreDomUtils.showErrorModal(error);
await CoreNavigator.back();
return;
}
if (!('subject' in notification)) {
// Try to find the notification using the WebService, it contains a better message.
const notifId = Number(notification.savedmessageid);
const result = await CoreUtils.ignoreErrors(
AddonNotifications.getNotifications([], { siteId: notification.site }),
);
const foundNotification = result?.notifications.find(notif => notif.id === notifId);
if (foundNotification) {
notification = AddonNotificationsHelper.formatNotificationText(foundNotification);
}
}
if ('subject' in notification) {
this.subject = notification.subject;
this.content = notification.mobiletext || notification.fullmessagehtml;
@ -89,8 +76,10 @@ export class AddonNotificationsNotificationPage implements OnInit {
this.iconUrl = notification.iconurl;
if (notification.moodlecomponent?.startsWith('mod_') && notification.iconurl) {
const modname = notification.moodlecomponent.substring(4);
if (notification.iconurl.match('/theme/image.php/[^/]+/' + modname + '/[-0-9]*/') ||
notification.iconurl.match('/theme/image.php/[^/]+/' + notification.moodlecomponent + '/[-0-9]*/')) {
if (
notification.iconurl.match('/theme/image.php/[^/]+/' + modname + '/[-0-9]*/') ||
notification.iconurl.match('/theme/image.php/[^/]+/' + notification.moodlecomponent + '/[-0-9]*/')
) {
this.modname = modname;
}
}
@ -110,6 +99,39 @@ export class AddonNotificationsNotificationPage implements OnInit {
this.loaded = true;
}
/**
* Load notifications
*
* @param notificationId Notification id.
* @return Found notification
*/
loadNotifications(notificationId: number): AddonNotificationsNotification {
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
AddonsNotificationsNotificationsSource,
[],
);
const notification = source.getItems()?.find(({ id }) => id === notificationId);
if (!notification) {
throw new CoreError(`Notification with id ${notificationId} not found`);
}
return notification;
}
/**
* Load current notification if it's found
*
* @return Found notification
*/
getCurrentNotification(): AddonNotificationsNotification {
const pushNotification: AddonNotificationsNotificationData | undefined = CoreNavigator.getRouteParam('notification');
return this.loadNotifications(
pushNotification ? Number(pushNotification?.savedmessageid) : CoreNavigator.getRequiredRouteNumberParam('id'),
);
}
/**
* Load notification actions
*