MOBILE-3833 notifications: Open notifications on a different page
parent
90ddcd7827
commit
ef1222b148
|
@ -1,90 +0,0 @@
|
||||||
// (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<string, unknown>; // Extra data to handle the URL.
|
|
||||||
|
|
||||||
actions: CoreContentLinksAction[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component being initialized.
|
|
||||||
*/
|
|
||||||
async ngOnInit(): Promise<void> {
|
|
||||||
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.getActionsFor(
|
|
||||||
<string> 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.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.
|
|
||||||
*/
|
|
||||||
protected async openInBrowser(siteId?: string): Promise<void> {
|
|
||||||
const url = <string> this.data?.appurl || this.contextUrl;
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const site = await CoreSites.getSite(siteId);
|
|
||||||
|
|
||||||
site.openInBrowserWithAutoLogin(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
<ion-row *ngIf="actions && actions.length > 0" class="justify-content-around">
|
|
||||||
<ion-col *ngFor="let action of actions">
|
|
||||||
<ion-button fill="clear" expand="block" (click)="action.action()">
|
|
||||||
<ion-icon slot="start" name="{{action.icon}}" aria-hidden="true"></ion-icon>
|
|
||||||
{{ action.message | translate }}
|
|
||||||
</ion-button>
|
|
||||||
</ion-col>
|
|
||||||
</ion-row>
|
|
|
@ -1,31 +0,0 @@
|
||||||
// (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 { CoreSharedModule } from '@/core/shared.module';
|
|
||||||
|
|
||||||
import { AddonNotificationsActionsComponent } from './actions/actions';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
declarations: [
|
|
||||||
AddonNotificationsActionsComponent,
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CoreSharedModule,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
AddonNotificationsActionsComponent,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class AddonNotificationsComponentsModule {}
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
--extra-icon-size: 16px;
|
--extra-icon-size: 16px;
|
||||||
|
--icon-size: 24px;
|
||||||
|
|
||||||
::ng-deep core-user-avatar {
|
::ng-deep core-user-avatar {
|
||||||
.core-avatar-extra-img,
|
.core-avatar-extra-img,
|
||||||
|
@ -28,71 +29,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.core-notification-icon {
|
div.core-notification-icon {
|
||||||
width: var(--core-avatar-size);
|
img {
|
||||||
height: var(--core-avatar-size);
|
width: var(--icon-size);
|
||||||
@include margin(6px, 8px, 6px, 0px);
|
height: var(--icon-size);
|
||||||
|
}
|
||||||
|
padding: 0.7rem;
|
||||||
background: var(--background-color);
|
background: var(--background-color);
|
||||||
border-radius: var(--small-radius);
|
border-radius: var(--small-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item core-format-text ::ng-deep {
|
.core-notification-icon {
|
||||||
.forumpost {
|
--module-icon-size: var(--icon-size);
|
||||||
border: 1px solid var(--gray-200);
|
@include margin(6px, 8px, 6px, 0px);
|
||||||
width: 100%;
|
|
||||||
margin: 0 0 1em 0;
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background-color: var(--light);
|
|
||||||
|
|
||||||
.picture {
|
|
||||||
width: 48px;
|
|
||||||
text-align: center;
|
|
||||||
@include padding-horizontal(4px, 0px);
|
|
||||||
padding-top: 8px;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 44px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-200);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,16 +11,14 @@
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content>
|
<ion-content class="limited-width">
|
||||||
<ion-refresher slot="fixed" [disabled]="!notificationsLoaded" (ionRefresh)="refreshNotifications($event.target)">
|
<ion-refresher slot="fixed" [disabled]="!notificationsLoaded" (ionRefresh)="refreshNotifications($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]="notificationsLoaded">
|
<core-loading [hideUntil]="notificationsLoaded">
|
||||||
<div class="list-item-limited-width">
|
|
||||||
|
|
||||||
<div class="ion-padding" *ngIf="canMarkAllNotificationsAsRead">
|
<div class="ion-padding" *ngIf="canMarkAllNotificationsAsRead">
|
||||||
<ion-button [disabled]="loadingMarkAllNotificationsAsRead" expand="block" (click)="markAllNotificationsAsRead()"
|
<ion-button [disabled]="loadingMarkAllNotificationsAsRead" expand="block" (click)="markAllNotificationsAsRead()" fill="outline">
|
||||||
fill="outline">
|
|
||||||
<ion-icon slot="start" name="fas-check" aria-hidden="true" *ngIf="!loadingMarkAllNotificationsAsRead"></ion-icon>
|
<ion-icon slot="start" name="fas-check" aria-hidden="true" *ngIf="!loadingMarkAllNotificationsAsRead"></ion-icon>
|
||||||
<ion-spinner slot="start" [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead">
|
<ion-spinner slot="start" [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead">
|
||||||
</ion-spinner>
|
</ion-spinner>
|
||||||
|
@ -28,11 +26,11 @@
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ion-card *ngFor="let notification of notifications">
|
<ion-item *ngFor="let notification of notifications" class="ion-text-wrap" [attr.aria-label]="
|
||||||
<ion-item class="ion-text-wrap" [attr.aria-label]="
|
|
||||||
notification.timeread
|
notification.timeread
|
||||||
? notification.subject
|
? notification.subject
|
||||||
: 'addon.notifications.unreadnotification' | translate: {$a: notification.subject}">
|
: 'addon.notifications.unreadnotification' | translate: {$a: notification.subject}"
|
||||||
|
(click)="openNotification(notification)" button [detail]="true" lines="true">
|
||||||
<core-user-avatar *ngIf="notification.useridfrom > 0" [user]="notification" slot="start"
|
<core-user-avatar *ngIf="notification.useridfrom > 0" [user]="notification" slot="start"
|
||||||
[profileUrl]="notification.profileimageurlfrom" [fullname]="notification.userfromfullname"
|
[profileUrl]="notification.profileimageurlfrom" [fullname]="notification.userfromfullname"
|
||||||
[userId]="notification.useridfrom">
|
[userId]="notification.useridfrom">
|
||||||
|
@ -44,36 +42,29 @@
|
||||||
</core-mod-icon>
|
</core-mod-icon>
|
||||||
</core-user-avatar>
|
</core-user-avatar>
|
||||||
|
|
||||||
<img *ngIf="notification.useridfrom <= 0 && notification.iconurl" [src]="notification.iconurl" alt=""
|
<ng-container *ngIf="notification.useridfrom <= 0 && notification.iconurl">
|
||||||
role="presentation" class="core-notification-icon" slot="start">
|
<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>
|
||||||
|
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p class="item-heading">
|
<p class="item-heading">
|
||||||
<core-format-text [text]="notification.subject" contextLevel="system" [contextInstanceId]="0"
|
<core-format-text [text]="notification.subject" contextLevel="system" [contextInstanceId]="0" [wsNotFiltered]="true">
|
||||||
[wsNotFiltered]="true">
|
|
||||||
</core-format-text>
|
</core-format-text>
|
||||||
</p>
|
</p>
|
||||||
<p *ngIf="notification.useridfrom > 0">{{ notification.userfromfullname }}</p>
|
<p *ngIf="notification.useridfrom > 0">{{ notification.userfromfullname }}</p>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-note slot="end" class="ion-float-end ion-text-end">
|
<ion-note slot="end">
|
||||||
{{ notification.timecreated | coreDateDayOrTime }}
|
{{ notification.timecreated | coreDateDayOrTime }}
|
||||||
<span *ngIf="!notification.timeread">
|
<span *ngIf="!notification.timeread">
|
||||||
<ion-icon name="fas-circle" color="primary" aria-hidden="true"></ion-icon>
|
<ion-icon name="fas-circle" color="primary" aria-hidden="true"></ion-icon>
|
||||||
</span>
|
</span>
|
||||||
</ion-note>
|
</ion-note>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item class="ion-text-wrap">
|
|
||||||
<ion-label>
|
|
||||||
<core-format-text [text]="notification.mobiletext | coreCreateLinks" contextLevel="system" [contextInstanceId]="0"
|
|
||||||
collapsible-item>
|
|
||||||
</core-format-text>
|
|
||||||
</ion-label>
|
|
||||||
</ion-item>
|
|
||||||
<addon-notifications-actions [contextUrl]="notification.contexturl" [courseId]="notification.courseid"
|
|
||||||
[data]="notification.customdata">
|
|
||||||
</addon-notifications-actions>
|
|
||||||
</ion-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<core-empty-box *ngIf="!notifications || notifications.length <= 0" icon="far-bell"
|
<core-empty-box *ngIf="!notifications || notifications.length <= 0" icon="far-bell"
|
||||||
[message]="'addon.notifications.therearentnotificationsyet' | translate">
|
[message]="'addon.notifications.therearentnotificationsyet' | translate">
|
||||||
|
|
|
@ -16,7 +16,6 @@ import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
|
||||||
import { CoreSharedModule } from '@/core/shared.module';
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
import { AddonNotificationsComponentsModule } from '../../components/components.module';
|
|
||||||
import { AddonNotificationsListPage } from './list';
|
import { AddonNotificationsListPage } from './list';
|
||||||
import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module';
|
import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module';
|
||||||
|
|
||||||
|
@ -31,7 +30,6 @@ const routes: Routes = [
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild(routes),
|
RouterModule.forChild(routes),
|
||||||
CoreSharedModule,
|
CoreSharedModule,
|
||||||
AddonNotificationsComponentsModule,
|
|
||||||
CoreMainMenuComponentsModule,
|
CoreMainMenuComponentsModule,
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
|
|
|
@ -30,6 +30,8 @@ import {
|
||||||
AddonNotificationsNotificationToRender,
|
AddonNotificationsNotificationToRender,
|
||||||
} from '@addons/notifications/services/notifications-helper';
|
} from '@addons/notifications/services/notifications-helper';
|
||||||
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
|
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
|
||||||
|
import { CoreNavigator } from '@services/navigator';
|
||||||
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page that displays the list of notifications.
|
* Page that displays the list of notifications.
|
||||||
|
@ -50,6 +52,7 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
protected isCurrentView?: boolean;
|
protected isCurrentView?: boolean;
|
||||||
protected cronObserver?: CoreEventObserver;
|
protected cronObserver?: CoreEventObserver;
|
||||||
|
protected readObserver?: CoreEventObserver;
|
||||||
protected pushObserver?: Subscription;
|
protected pushObserver?: Subscription;
|
||||||
protected pendingRefresh = false;
|
protected pendingRefresh = false;
|
||||||
|
|
||||||
|
@ -84,6 +87,21 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
|
||||||
this.refreshNotifications();
|
this.refreshNotifications();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.readObserver = CoreEvents.on(AddonNotificationsProvider.READ_CHANGED_EVENT, (data) => {
|
||||||
|
if (!data.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = this.notifications.find((notification) => notification.id === data.id);
|
||||||
|
if (!notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.read = true;
|
||||||
|
notification.timeread = data.time;
|
||||||
|
this.loadMarkAllAsReadButton();
|
||||||
|
});
|
||||||
|
|
||||||
const deepLinkManager = new CoreMainMenuDeepLinkManager();
|
const deepLinkManager = new CoreMainMenuDeepLinkManager();
|
||||||
deepLinkManager.treatLink();
|
deepLinkManager.treatLink();
|
||||||
}
|
}
|
||||||
|
@ -91,7 +109,7 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
|
||||||
/**
|
/**
|
||||||
* Convenience function to get notifications. Gets unread notifications first.
|
* Convenience function to get notifications. Gets unread notifications first.
|
||||||
*
|
*
|
||||||
* @param refreh Whether we're refreshing data.
|
* @param refresh Whether we're refreshing data.
|
||||||
* @return Resolved when done.
|
* @return Resolved when done.
|
||||||
*/
|
*/
|
||||||
protected async fetchNotifications(refresh?: boolean): Promise<void> {
|
protected async fetchNotifications(refresh?: boolean): Promise<void> {
|
||||||
|
@ -110,7 +128,7 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
this.canLoadMore = result.canLoadMore;
|
this.canLoadMore = result.canLoadMore;
|
||||||
|
|
||||||
this.markNotificationsAsRead(notifications);
|
await this.loadMarkAllAsReadButton();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
CoreDomUtils.showErrorModalDefault(error, 'addon.notifications.errorgetnotifications', true);
|
CoreDomUtils.showErrorModalDefault(error, 'addon.notifications.errorgetnotifications', true);
|
||||||
this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading.
|
this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading.
|
||||||
|
@ -129,7 +147,9 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
await CoreUtils.ignoreErrors(AddonNotifications.markAllNotificationsAsRead());
|
await CoreUtils.ignoreErrors(AddonNotifications.markAllNotificationsAsRead());
|
||||||
|
|
||||||
CoreEvents.trigger(AddonNotificationsProvider.READ_CHANGED_EVENT, {}, CoreSites.getCurrentSiteId());
|
CoreEvents.trigger(AddonNotificationsProvider.READ_CHANGED_EVENT, {
|
||||||
|
time: CoreTimeUtils.timestamp(),
|
||||||
|
}, CoreSites.getCurrentSiteId());
|
||||||
|
|
||||||
// All marked as read, refresh the list.
|
// All marked as read, refresh the list.
|
||||||
this.notificationsLoaded = false;
|
this.notificationsLoaded = false;
|
||||||
|
@ -140,26 +160,25 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
|
||||||
/**
|
/**
|
||||||
* Mark notifications as read.
|
* Mark notifications as read.
|
||||||
*
|
*
|
||||||
* @param notifications Array of notification objects.
|
* @param notification Array of notification objects.
|
||||||
|
* @return Promise resolved when done.
|
||||||
*/
|
*/
|
||||||
protected async markNotificationsAsRead(notifications: AddonNotificationsNotificationToRender[]): Promise<void> {
|
protected async markNotificationAsRead(notification: AddonNotificationsNotificationToRender): Promise<void> {
|
||||||
if (notifications.length > 0) {
|
const updated = await AddonNotificationsHelper.markNotificationAsRead(notification);
|
||||||
const promises = notifications.map(async (notification) => {
|
|
||||||
if (notification.read) {
|
if (!updated) {
|
||||||
// Already read, don't mark it.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await AddonNotifications.markNotificationRead(notification.id);
|
await this.loadMarkAllAsReadButton();
|
||||||
});
|
|
||||||
|
|
||||||
await CoreUtils.ignoreErrors(Promise.all(promises));
|
|
||||||
|
|
||||||
await CoreUtils.ignoreErrors(AddonNotifications.invalidateNotificationsList());
|
|
||||||
|
|
||||||
CoreEvents.trigger(AddonNotificationsProvider.READ_CHANGED_EVENT, {}, CoreSites.getCurrentSiteId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load mark all notifications as read button.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async loadMarkAllAsReadButton(): Promise<void> {
|
||||||
// Check if mark all as read should be displayed (there are unread notifications).
|
// Check if mark all as read should be displayed (there are unread notifications).
|
||||||
try {
|
try {
|
||||||
this.loadingMarkAllNotificationsAsRead = true;
|
this.loadingMarkAllNotificationsAsRead = true;
|
||||||
|
@ -201,6 +220,15 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open Notification page.
|
||||||
|
*
|
||||||
|
* @param notification Notification to open.
|
||||||
|
*/
|
||||||
|
openNotification(notification: AddonNotificationsNotificationToRender): void {
|
||||||
|
CoreNavigator.navigate('../notification', { params: { notification } });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User entered the page.
|
* User entered the page.
|
||||||
*/
|
*/
|
||||||
|
@ -229,6 +257,7 @@ export class AddonNotificationsListPage implements OnInit, OnDestroy {
|
||||||
*/
|
*/
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.cronObserver?.off();
|
this.cronObserver?.off();
|
||||||
|
this.readObserver?.off();
|
||||||
this.pushObserver?.unsubscribe();
|
this.pushObserver?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,13 +4,16 @@
|
||||||
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
|
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
<ion-title>
|
<ion-title>
|
||||||
<h2>{{ 'addon.notifications.notifications' | translate }}</h2>
|
<h1>{{ 'addon.notifications.notifications' | translate }}</h1>
|
||||||
</ion-title>
|
</ion-title>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content>
|
<ion-content>
|
||||||
<core-loading [hideUntil]="loaded">
|
<core-loading [hideUntil]="loaded">
|
||||||
<ion-item class="ion-text-wrap" [attr.aria-label]="subject">
|
<div class="list-item-limited-width">
|
||||||
|
<ion-card>
|
||||||
|
|
||||||
|
<ion-item class="ion-text-wrap">
|
||||||
<core-user-avatar *ngIf="userIdFrom > 0" slot="start" [userId]="userIdFrom" [profileUrl]="profileImageUrlFrom"
|
<core-user-avatar *ngIf="userIdFrom > 0" slot="start" [userId]="userIdFrom" [profileUrl]="profileImageUrlFrom"
|
||||||
[fullname]="userFromFullName">
|
[fullname]="userFromFullName">
|
||||||
<div class="core-avatar-extra-img" *ngIf="iconUrl && !modname">
|
<div class="core-avatar-extra-img" *ngIf="iconUrl && !modname">
|
||||||
|
@ -20,12 +23,25 @@
|
||||||
</core-mod-icon>
|
</core-mod-icon>
|
||||||
</core-user-avatar>
|
</core-user-avatar>
|
||||||
|
|
||||||
<img *ngIf="userIdFrom <= 0 && iconUrl" [src]="iconUrl" alt="" role="presentation" class="core-notification-icon" slot="start">
|
<ng-container *ngIf="userIdFrom <= 0 && iconUrl">
|
||||||
|
<div class="core-notification-icon" *ngIf="!modname" slot="start">
|
||||||
|
<img [src]="iconUrl" alt="" role="presentation">
|
||||||
|
</div>
|
||||||
|
<core-mod-icon *ngIf="modname" [modicon]="iconUrl" [modname]="modname" [showAlt]="false"
|
||||||
|
class="core-notification-icon" slot="start">
|
||||||
|
</core-mod-icon>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p class="item-heading">{{ subject }}</p>
|
<ion-card-subtitle *ngIf="userIdFrom > 0">{{ userFromFullName }}</ion-card-subtitle>
|
||||||
<p *ngIf="userIdFrom > 0">{{ userFromFullName }}</p>
|
<ion-card-title>
|
||||||
|
<core-format-text [text]="subject" contextLevel="system" [contextInstanceId]="0" [wsNotFiltered]="true">
|
||||||
|
</core-format-text>
|
||||||
|
</ion-card-title>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
|
<ion-note slot="end" *ngIf="timecreated">
|
||||||
|
{{ timecreated | coreDateDayOrTime }}
|
||||||
|
</ion-note>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item class="ion-text-wrap">
|
<ion-item class="ion-text-wrap">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
|
@ -33,5 +49,16 @@
|
||||||
</core-format-text>
|
</core-format-text>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
</ion-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div collapsible-footer appearOnBottom *ngIf="loaded && actions && actions.length > 0" slot="fixed">
|
||||||
|
<div class="list-item-limited-width adaptable-buttons-row">
|
||||||
|
<ion-button expand="block" (click)="action.action()" *ngFor="let action of actions">
|
||||||
|
<ion-icon slot="start" name="{{action.icon}}" aria-hidden="true"></ion-icon>
|
||||||
|
{{ action.message | translate }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</core-loading>
|
</core-loading>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|
|
@ -16,7 +16,6 @@ import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
|
||||||
import { CoreSharedModule } from '@/core/shared.module';
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
import { AddonNotificationsComponentsModule } from '../../components/components.module';
|
|
||||||
import { AddonNotificationsNotificationPage } from './notification';
|
import { AddonNotificationsNotificationPage } from './notification';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
|
@ -30,7 +29,6 @@ const routes: Routes = [
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild(routes),
|
RouterModule.forChild(routes),
|
||||||
CoreSharedModule,
|
CoreSharedModule,
|
||||||
AddonNotificationsComponentsModule,
|
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
AddonNotificationsNotificationPage,
|
AddonNotificationsNotificationPage,
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
@import "~theme/globals";
|
||||||
|
|
||||||
|
:host {
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
core-format-text ::ng-deep {
|
||||||
|
.forumpost {
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: var(--light);
|
||||||
|
|
||||||
|
.picture {
|
||||||
|
width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
@include padding-horizontal(4px, 0px);
|
||||||
|
padding-top: 8px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 44px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,7 +19,9 @@ import {
|
||||||
AddonNotificationsNotificationToRender,
|
AddonNotificationsNotificationToRender,
|
||||||
} from '@addons/notifications/services/notifications-helper';
|
} from '@addons/notifications/services/notifications-helper';
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CoreContentLinksAction, CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
|
||||||
|
@ -29,7 +31,7 @@ import { CoreUtils } from '@services/utils/utils';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'page-addon-notifications-notification',
|
selector: 'page-addon-notifications-notification',
|
||||||
templateUrl: 'notification.html',
|
templateUrl: 'notification.html',
|
||||||
styleUrls: ['../../notifications.scss'],
|
styleUrls: ['../../notifications.scss', 'notification.scss'],
|
||||||
})
|
})
|
||||||
export class AddonNotificationsNotificationPage implements OnInit {
|
export class AddonNotificationsNotificationPage implements OnInit {
|
||||||
|
|
||||||
|
@ -41,12 +43,19 @@ export class AddonNotificationsNotificationPage implements OnInit {
|
||||||
iconUrl?: string; // Icon URL.
|
iconUrl?: string; // Icon URL.
|
||||||
modname?: string; // Module name.
|
modname?: string; // Module name.
|
||||||
loaded = false;
|
loaded = false;
|
||||||
|
timecreated = 0;
|
||||||
|
|
||||||
|
// Actions data.
|
||||||
|
actions: CoreContentLinksAction[] = [];
|
||||||
|
contextUrl?: string;
|
||||||
|
courseId?: number;
|
||||||
|
actionsData?: Record<string, unknown>; // Extra data to handle the URL.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
let notification: AddonNotificationsNotificationToRender | AddonNotificationsNotificationData;
|
let notification: AddonNotificationsNotification;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
notification = CoreNavigator.getRequiredRouteParam('notification');
|
notification = CoreNavigator.getRequiredRouteParam('notification');
|
||||||
|
@ -85,6 +94,8 @@ export class AddonNotificationsNotificationPage implements OnInit {
|
||||||
this.modname = modname;
|
this.modname = modname;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.timecreated = notification.timecreated;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.subject = notification.title || '';
|
this.subject = notification.title || '';
|
||||||
this.content = notification.message || '';
|
this.content = notification.message || '';
|
||||||
|
@ -93,7 +104,73 @@ export class AddonNotificationsNotificationPage implements OnInit {
|
||||||
this.userFromFullName = notification.userfromfullname;
|
this.userFromFullName = notification.userfromfullname;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.loadActions(notification);
|
||||||
|
AddonNotificationsHelper.markNotificationAsRead(notification);
|
||||||
|
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load notification actions
|
||||||
|
*
|
||||||
|
* @param notification Notification.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async loadActions(notification: AddonNotificationsNotification): Promise<void> {
|
||||||
|
if (!notification.contexturl && (!notification.customdata || !notification.customdata.appurl)) {
|
||||||
|
// No URL, nothing to do.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let actions: CoreContentLinksAction[] = [];
|
||||||
|
this.actionsData = notification.customdata;
|
||||||
|
this.contextUrl = notification.contexturl;
|
||||||
|
this.courseId = 'courseid' in notification ? notification.courseid : undefined;
|
||||||
|
|
||||||
|
// Treat appurl first if any.
|
||||||
|
if (this.actionsData?.appurl) {
|
||||||
|
actions = await CoreContentLinksDelegate.getActionsFor(
|
||||||
|
<string> this.actionsData.appurl,
|
||||||
|
this.courseId,
|
||||||
|
undefined,
|
||||||
|
this.actionsData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actions.length && this.contextUrl) {
|
||||||
|
// No appurl or cannot handle it. Try with contextUrl.
|
||||||
|
actions = await CoreContentLinksDelegate.getActionsFor(this.contextUrl, this.courseId, undefined, this.actionsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
protected async openInBrowser(siteId?: string): Promise<void> {
|
||||||
|
const url = <string> this.actionsData?.appurl || this.contextUrl;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
|
||||||
|
site.openInBrowserWithAutoLogin(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddonNotificationsNotification = AddonNotificationsNotificationToRender | AddonNotificationsNotificationData;
|
||||||
|
|
|
@ -18,12 +18,12 @@ import { CoreNavigator } from '@services/navigator';
|
||||||
import { CoreTextUtils } from '@services/utils/text';
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { makeSingleton } from '@singletons';
|
import { makeSingleton } from '@singletons';
|
||||||
import { CoreEvents } from '@singletons/events';
|
|
||||||
import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate';
|
import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate';
|
||||||
import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications';
|
import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications';
|
||||||
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
|
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
|
||||||
import { AddonNotifications, AddonNotificationsProvider } from '../notifications';
|
import { AddonNotifications } from '../notifications';
|
||||||
import { AddonNotificationsMainMenuHandlerService } from './mainmenu';
|
import { AddonNotificationsMainMenuHandlerService } from './mainmenu';
|
||||||
|
import { AddonNotificationsHelper } from '../notifications-helper';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for non-messaging push notifications clicks.
|
* Handler for non-messaging push notifications clicks.
|
||||||
|
@ -64,15 +64,7 @@ export class AddonNotificationsPushClickHandlerService implements CorePushNotifi
|
||||||
* @return Promise resolved when done.
|
* @return Promise resolved when done.
|
||||||
*/
|
*/
|
||||||
protected async markAsRead(notification: AddonNotificationsNotificationData): Promise<void> {
|
protected async markAsRead(notification: AddonNotificationsNotificationData): Promise<void> {
|
||||||
const notifId = notification.savedmessageid || notification.id;
|
await CoreUtils.ignoreErrors(AddonNotificationsHelper.markNotificationAsRead(notification));
|
||||||
|
|
||||||
if (!notifId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await CoreUtils.ignoreErrors(AddonNotifications.markNotificationRead(notifId, notification.site));
|
|
||||||
|
|
||||||
CoreEvents.trigger(AddonNotificationsProvider.READ_CHANGED_EVENT, {}, notification.site);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -18,13 +18,18 @@ import { CoreUtils } from '@services/utils/utils';
|
||||||
import { makeSingleton } from '@singletons';
|
import { makeSingleton } from '@singletons';
|
||||||
import { AddonMessageOutputDelegate } from '@addons/messageoutput/services/messageoutput-delegate';
|
import { AddonMessageOutputDelegate } from '@addons/messageoutput/services/messageoutput-delegate';
|
||||||
import {
|
import {
|
||||||
|
AddonNotifications,
|
||||||
AddonNotificationsNotificationMessageFormatted,
|
AddonNotificationsNotificationMessageFormatted,
|
||||||
AddonNotificationsPreferences,
|
AddonNotificationsPreferences,
|
||||||
AddonNotificationsPreferencesComponent,
|
AddonNotificationsPreferencesComponent,
|
||||||
AddonNotificationsPreferencesNotification,
|
AddonNotificationsPreferencesNotification,
|
||||||
AddonNotificationsPreferencesNotificationProcessor,
|
AddonNotificationsPreferencesNotificationProcessor,
|
||||||
AddonNotificationsPreferencesProcessor,
|
AddonNotificationsPreferencesProcessor,
|
||||||
|
AddonNotificationsProvider,
|
||||||
} from './notifications';
|
} from './notifications';
|
||||||
|
import { CoreEvents } from '@singletons/events';
|
||||||
|
import { AddonNotificationsNotificationData } from './handlers/push-click';
|
||||||
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service that provides some helper functions for notifications.
|
* Service that provides some helper functions for notifications.
|
||||||
|
@ -115,6 +120,46 @@ export class AddonNotificationsHelperProvider {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notification as read, trigger event and invalidate data.
|
||||||
|
*
|
||||||
|
* @param notification Notification object.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async markNotificationAsRead(
|
||||||
|
notification: AddonNotificationsNotificationMessageFormatted | AddonNotificationsNotificationData,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if ('read' in notification && (notification.read || notification.timeread > 0)) {
|
||||||
|
// Already read, don't mark it.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifId = 'savedmessageid' in notification ? notification.savedmessageid || notification.id : notification.id;
|
||||||
|
if (!notifId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
siteId = 'site' in notification ? notification.site : siteId;
|
||||||
|
|
||||||
|
await CoreUtils.ignoreErrors(AddonNotifications.markNotificationRead(notifId, siteId));
|
||||||
|
|
||||||
|
const time = CoreTimeUtils.timestamp();
|
||||||
|
if ('read' in notification) {
|
||||||
|
notification.read = true;
|
||||||
|
notification.timeread = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CoreUtils.ignoreErrors(AddonNotifications.invalidateNotificationsList());
|
||||||
|
|
||||||
|
CoreEvents.trigger(AddonNotificationsProvider.READ_CHANGED_EVENT, {
|
||||||
|
id: notifId,
|
||||||
|
time,
|
||||||
|
}, siteId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddonNotificationsHelper = makeSingleton(AddonNotificationsHelperProvider);
|
export const AddonNotificationsHelper = makeSingleton(AddonNotificationsHelperProvider);
|
||||||
|
|
|
@ -24,6 +24,19 @@ import { CoreLogger } from '@singletons/logger';
|
||||||
import { makeSingleton } from '@singletons';
|
import { makeSingleton } from '@singletons';
|
||||||
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
|
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
|
||||||
|
|
||||||
|
declare module '@singletons/events' {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Augment CoreEventsData interface with events specific to this service.
|
||||||
|
*
|
||||||
|
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
|
||||||
|
*/
|
||||||
|
export interface CoreEventsData {
|
||||||
|
[AddonNotificationsProvider.READ_CHANGED_EVENT]: AddonNotificationsReadChangedEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
const ROOT_CACHE_KEY = 'mmaNotifications:';
|
const ROOT_CACHE_KEY = 'mmaNotifications:';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -577,3 +590,11 @@ export enum AddonNotificationsGetReadType {
|
||||||
READ = 1,
|
READ = 1,
|
||||||
BOTH = 2,
|
BOTH = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event triggered when one or more notifications are read.
|
||||||
|
*/
|
||||||
|
export type AddonNotificationsReadChangedEvent = {
|
||||||
|
id?: number; // Set to the single id notification read. Undefined if multiple.
|
||||||
|
time: number; // Time of the change.
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue