From 5dcd11cd82e24289881d55b843239901ecd84be0 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 20 Jan 2023 15:08:44 +0200 Subject: [PATCH] UI: Add ws to show notification --- .../core/ws/notification-websocket.service.ts | 336 ++++++++++++++++++ .../notification-bell.component.html | 20 ++ .../notification-bell.component.ts | 86 +++++ .../show-notification-popover.component.html | 40 +++ .../show-notification-popover.component.ts | 93 +++++ .../src/app/modules/home/home.component.html | 1 + ui-ngx/src/app/modules/home/home.module.ts | 6 +- .../notification-table/inbox-table-config.ts | 2 +- .../notification/notification.component.html | 30 ++ .../notification/notification.component.scss | 53 +++ .../notification/notification.component.ts | 41 +++ .../shared/models/notification-ws.models.ts | 264 ++++++++++++++ ui-ngx/src/app/shared/models/public-api.ts | 1 + .../models/telemetry/telemetry.models.ts | 4 +- ui-ngx/src/app/shared/pipe/date-ago.pipe.ts | 57 +++ ui-ngx/src/app/shared/pipe/public-api.ts | 1 + ui-ngx/src/app/shared/shared.module.ts | 14 +- .../assets/locale/locale.constant-en_US.json | 13 +- 18 files changed, 1055 insertions(+), 7 deletions(-) create mode 100644 ui-ngx/src/app/core/ws/notification-websocket.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/notification/notification-bell.component.html create mode 100644 ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html create mode 100644 ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts create mode 100644 ui-ngx/src/app/shared/components/notification/notification.component.html create mode 100644 ui-ngx/src/app/shared/components/notification/notification.component.scss create mode 100644 ui-ngx/src/app/shared/components/notification/notification.component.ts create mode 100644 ui-ngx/src/app/shared/models/notification-ws.models.ts create mode 100644 ui-ngx/src/app/shared/pipe/date-ago.pipe.ts diff --git a/ui-ngx/src/app/core/ws/notification-websocket.service.ts b/ui-ngx/src/app/core/ws/notification-websocket.service.ts new file mode 100644 index 0000000000..020bdf4615 --- /dev/null +++ b/ui-ngx/src/app/core/ws/notification-websocket.service.ts @@ -0,0 +1,336 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// 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 { Inject, Injectable, NgZone } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AuthService } from '@core/auth/auth.service'; +import { selectIsAuthenticated } from '@core/auth/auth.selectors'; +import { WINDOW } from '@core/services/window.service'; +import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { + isNotificationCountUpdateMsg, + isNotificationsUpdateMsg, + MarkAllAsReadCmd, + MarkAsReadCmd, + NotificationCountUpdate, + NotificationPluginCmdsWrapper, + NotificationSubscriber, + NotificationsUpdate, + NotificationWsService, + UnreadCountSubCmd, + UnreadSubCmd, + UnsubscribeCmd, + WebsocketNotificationMsg +} from '@shared/models/notification-ws.models'; +import Timeout = NodeJS.Timeout; + +const RECONNECT_INTERVAL = 2000; +const WS_IDLE_TIMEOUT = 90000; + +// @dynamic +@Injectable({ + providedIn: 'root' +}) +export class NotificationWebsocketService implements NotificationWsService { + + isActive = false; + isOpening = false; + isOpened = false; + isReconnect = false; + + socketCloseTimer: Timeout; + reconnectTimer: Timeout; + + lastCmdId = 0; + subscribersCount = 0; + subscribersMap = new Map(); + + reconnectSubscribers = new Set(); + + cmdsWrapper = new NotificationPluginCmdsWrapper(); + notificationUri: string; + + dataStream: WebSocketSubject; + + constructor(private store: Store, + private authService: AuthService, + private ngZone: NgZone, + @Inject(WINDOW) private window: Window) { + this.store.pipe(select(selectIsAuthenticated)).subscribe( + () => { + this.reset(true); + } + ); + + let port = this.window.location.port; + if (this.window.location.protocol === 'https:') { + if (!port) { + port = '443'; + } + this.notificationUri = 'wss:'; + } else { + if (!port) { + port = '80'; + } + this.notificationUri = 'ws:'; + } + this.notificationUri += `//${this.window.location.hostname}:${port}/api/ws/plugins/notifications`; + } + + public subscribe(subscriber: NotificationSubscriber) { + this.isActive = true; + subscriber.subscriptionCommands.forEach( + (subscriptionCommand) => { + const cmdId = this.nextCmdId(); + this.subscribersMap.set(cmdId, subscriber); + subscriptionCommand.cmdId = cmdId; + if (subscriptionCommand instanceof UnreadCountSubCmd) { + this.cmdsWrapper.unreadCountSubCmd = subscriptionCommand; + } else if (subscriptionCommand instanceof UnreadSubCmd) { + this.cmdsWrapper.unreadSubCmd = subscriptionCommand; + } else if (subscriptionCommand instanceof MarkAsReadCmd) { + this.cmdsWrapper.markAsReadCmd = subscriptionCommand; + this.subscribersMap.delete(cmdId); + } else if (subscriptionCommand instanceof MarkAllAsReadCmd) { + this.cmdsWrapper.markAllAsReadCmd = subscriptionCommand; + this.subscribersMap.delete(cmdId); + } + } + ); + if (this.cmdsWrapper.markAsReadCmd || this.cmdsWrapper.markAllAsReadCmd) { + this.subscribersCount++; + } + this.publishCommands(); + } + + public update(subscriber: NotificationSubscriber) { + if (!this.isReconnect) { + subscriber.subscriptionCommands.forEach( + (subscriptionCommand) => { + if (subscriptionCommand.cmdId && subscriptionCommand instanceof UnreadSubCmd) { + this.cmdsWrapper.unreadSubCmd = subscriptionCommand; + } + } + ); + this.publishCommands(); + } + } + + public unsubscribe(subscriber: NotificationSubscriber) { + if (this.isActive) { + subscriber.subscriptionCommands.forEach( + (subscriptionCommand) => { + if (subscriptionCommand instanceof UnreadCountSubCmd + || subscriptionCommand instanceof UnreadSubCmd) { + const unreadCountUnsubscribeCmd = new UnsubscribeCmd(); + unreadCountUnsubscribeCmd.cmdId = subscriptionCommand.cmdId; + this.cmdsWrapper.unsubCmd = unreadCountUnsubscribeCmd; + } + const cmdId = subscriptionCommand.cmdId; + if (cmdId) { + this.subscribersMap.delete(cmdId); + } + } + ); + this.reconnectSubscribers.delete(subscriber); + this.subscribersCount--; + this.publishCommands(); + } + } + + private nextCmdId(): number { + this.lastCmdId++; + return this.lastCmdId; + } + + private publishCommands() { + while (this.isOpened && this.cmdsWrapper.hasCommands()) { + this.dataStream.next(this.cmdsWrapper.preparePublishCommands()); + this.cmdsWrapper.clear(); + this.checkToClose(); + } + this.tryOpenSocket(); + } + + private checkToClose() { + if (this.subscribersCount === 0 && this.isOpened) { + if (!this.socketCloseTimer) { + this.socketCloseTimer = setTimeout( + () => this.closeSocket(), WS_IDLE_TIMEOUT); + } + } + } + + private reset(close: boolean) { + if (this.socketCloseTimer) { + clearTimeout(this.socketCloseTimer); + this.socketCloseTimer = null; + } + this.lastCmdId = 0; + this.subscribersMap.clear(); + this.subscribersCount = 0; + this.cmdsWrapper.clear(); + if (close) { + this.closeSocket(); + } + } + + private closeSocket() { + this.isActive = false; + if (this.isOpened) { + this.dataStream.unsubscribe(); + } + } + + private tryOpenSocket() { + if (this.isActive) { + if (!this.isOpened && !this.isOpening) { + this.isOpening = true; + if (AuthService.isJwtTokenValid()) { + this.openSocket(AuthService.getJwtToken()); + } else { + this.authService.refreshJwtToken().subscribe(() => { + this.openSocket(AuthService.getJwtToken()); + }, + () => { + this.isOpening = false; + this.authService.logout(true, true); + } + ); + } + } + if (this.socketCloseTimer) { + clearTimeout(this.socketCloseTimer); + this.socketCloseTimer = null; + } + } + } + + private openSocket(token: string) { + const uri = `${this.notificationUri}?token=${token}`; + this.dataStream = webSocket( + { + url: uri, + openObserver: { + next: () => { + this.onOpen(); + } + }, + closeObserver: { + next: (e: CloseEvent) => { + this.onClose(e); + } + } + } + ); + + this.dataStream.subscribe((message) => { + this.ngZone.runOutsideAngular(() => { + this.onMessage(message as WebsocketNotificationMsg); + }); + }, + (error) => { + this.onError(error); + }); + } + + private onOpen() { + this.isOpening = false; + this.isOpened = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.isReconnect) { + this.isReconnect = false; + this.reconnectSubscribers.forEach( + (reconnectSubscriber) => { + reconnectSubscriber.onReconnected(); + this.subscribe(reconnectSubscriber); + } + ); + this.reconnectSubscribers.clear(); + } else { + this.publishCommands(); + } + } + + private onMessage(message: WebsocketNotificationMsg) { + if (message.errorCode) { + this.showWsError(message.errorCode, message.errorMsg); + } else { + let subscriber: NotificationSubscriber; + if (isNotificationCountUpdateMsg(message)) { + subscriber = this.subscribersMap.get(message.cmdId); + if (subscriber) { + subscriber.onNotificationCountUpdate(new NotificationCountUpdate(message)); + } + } else if (isNotificationsUpdateMsg(message)) { + subscriber = this.subscribersMap.get(message.cmdId); + if (subscriber) { + subscriber.onNotificationsUpdate(new NotificationsUpdate(message)); + } + } + } + this.checkToClose(); + } + + private onError(errorEvent) { + if (errorEvent) { + console.warn('WebSocket error event', errorEvent); + } + this.isOpening = false; + } + + private onClose(closeEvent: CloseEvent) { + if (closeEvent && closeEvent.code > 1001 && closeEvent.code !== 1006 + && closeEvent.code !== 1011 && closeEvent.code !== 1012 && closeEvent.code !== 4500) { + this.showWsError(closeEvent.code, closeEvent.reason); + } + this.isOpening = false; + this.isOpened = false; + if (this.isActive) { + if (!this.isReconnect) { + this.reconnectSubscribers.clear(); + this.subscribersMap.forEach( + (subscriber) => { + this.reconnectSubscribers.add(subscriber); + } + ); + this.reset(false); + this.isReconnect = true; + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + } + this.reconnectTimer = setTimeout(() => this.tryOpenSocket(), RECONNECT_INTERVAL); + } + } + + private showWsError(errorCode: number, errorMsg: string) { + let message = errorMsg; + if (!message) { + message += `WebSocket Notification Error: error code - ${errorCode}.`; + } + this.store.dispatch(new ActionNotificationShow( + { + message, type: 'error' + })); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.html b/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.html new file mode 100644 index 0000000000..e4cb66dbb8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.html @@ -0,0 +1,20 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts b/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts new file mode 100644 index 0000000000..ff670fa167 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts @@ -0,0 +1,86 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// 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 { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + NgZone, + OnInit, + Renderer2, + ViewContainerRef +} from '@angular/core'; +import { NotificationWebsocketService } from '@core/ws/notification-websocket.service'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, publishReplay, refCount, tap } from 'rxjs/operators'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { ShowNotificationPopoverComponent } from '@home/components/notification/show-notification-popover.component'; +import { NotificationSubscriber } from '@shared/models/notification-ws.models'; + +@Component({ + selector: 'tb-notification-bell', + templateUrl: './notification-bell.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class NotificationBellComponent implements OnInit { + + private notificationSubscriber: NotificationSubscriber; + count$: Observable; + + constructor( + private notificationWsService: NotificationWebsocketService, + private zone: NgZone, + private cd: ChangeDetectorRef, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef) { + } + + ngOnInit() { + this.notificationSubscriber = NotificationSubscriber.createNotificationCountSubscription( + this.notificationWsService, this.zone); + this.notificationSubscriber.subscribe(); + this.count$ = this.notificationSubscriber.notificationCount$.pipe( + distinctUntilChanged(), + publishReplay(1), + refCount(), + tap(() => setTimeout(() => this.cd.markForCheck())), + ); + } + + showNotification($event: Event, createVersionButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = createVersionButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const showNotificationPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ShowNotificationPopoverComponent, 'bottom', true, null, + { + onClose: () => { + showNotificationPopover.hide(); + } + }, + {maxHeight: '100vh', height: '100%', padding: '10px'}, + {width: '400px', minWidth: '100%', maxWidth: '100%'}, + {height: '100%', flexDirection: 'column', boxSizing: 'border-box', display: 'flex'}, false); + showNotificationPopover.tbComponentRef.instance.popoverComponent = showNotificationPopover; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html new file mode 100644 index 0000000000..fa04d38253 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html @@ -0,0 +1,40 @@ + +
+
notification.notification
+ +
+ +
+
+ + +
+
+
+ +
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts new file mode 100644 index 0000000000..4f25f644ff --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts @@ -0,0 +1,93 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// 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 { ChangeDetectorRef, Component, Input, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Notification } from '@shared/models/notification.models'; +import { NotificationWebsocketService } from '@core/ws/notification-websocket.service'; +import { Observable } from 'rxjs'; +import { publishReplay, refCount, tap } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { NotificationSubscriber } from '@shared/models/notification-ws.models'; + +@Component({ + selector: 'tb-show-notification-popover', + templateUrl: './show-notification-popover.component.html', + styleUrls: [] +}) +export class ShowNotificationPopoverComponent extends PageComponent implements OnDestroy, OnInit { + + @Input() + onClose: () => void; + + @Input() + popoverComponent: TbPopoverComponent; + + private notificationSubscriber: NotificationSubscriber; + notifications$: Observable; + + constructor(protected store: Store, + private notificationWsService: NotificationWebsocketService, + private zone: NgZone, + private cd: ChangeDetectorRef, + private router: Router) { + super(store); + } + + ngOnInit() { + this.notificationSubscriber = NotificationSubscriber.createNotificationsSubscription( + this.notificationWsService, this.zone); + this.notifications$ = this.notificationSubscriber.notifications$.pipe( + publishReplay(1), + refCount(), + tap(() => { + setTimeout(() => { + this.cd.markForCheck(); + }); + }) + ); + this.notificationSubscriber.subscribe(); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.notificationSubscriber.unsubscribe(); + } + + markAsRead(id: string) { + const cmd = NotificationSubscriber.createMarkAsReadCommand(this.notificationWsService, [id]); + cmd.subscribe(); + } + + markAsAllRead($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const cmd = NotificationSubscriber.createMarkAllAsReadCommand(this.notificationWsService); + cmd.subscribe(); + } + + viewAll($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.onClose(); + this.router.navigateByUrl(this.router.createUrlTree(['notification-center'])).then(() => {}); + } +} diff --git a/ui-ngx/src/app/modules/home/home.component.html b/ui-ngx/src/app/modules/home/home.component.html index a4acfe0fe3..cbcfa8ce91 100644 --- a/ui-ngx/src/app/modules/home/home.component.html +++ b/ui-ngx/src/app/modules/home/home.component.html @@ -70,6 +70,7 @@ mat-icon-button fxHide.lt-md (click)="toggleFullscreen()"> {{ isFullscreen() ? 'fullscreen_exit' : 'fullscreen' }} + { this.headerComponent = InboxTableHeaderComponent; this.headerActionDescriptors = [{ - name: this.translate.instant('notification.mark-all-read'), + name: this.translate.instant('notification.mark-all-as-read'), icon: 'done_all', isEnabled: () => true, onAction: $event => this.markAllRead($event) diff --git a/ui-ngx/src/app/shared/components/notification/notification.component.html b/ui-ngx/src/app/shared/components/notification/notification.component.html new file mode 100644 index 0000000000..91c6b50f0a --- /dev/null +++ b/ui-ngx/src/app/shared/components/notification/notification.component.html @@ -0,0 +1,30 @@ + +
+ account_circle +
+
{{ notification.subject }}
+
{{ notification.text }}
+
+
+ + {{ notification.createdTime | dateAgo }} +
+
diff --git a/ui-ngx/src/app/shared/components/notification/notification.component.scss b/ui-ngx/src/app/shared/components/notification/notification.component.scss new file mode 100644 index 0000000000..50b568394d --- /dev/null +++ b/ui-ngx/src/app/shared/components/notification/notification.component.scss @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * 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. + */ +:host { + padding: 10px; + display: block; + + .mat-icon { + margin-right: 14px; + color: rgba(0, 0, 0, 0.54); + } + + .content { + letter-spacing: .25px; + .title { + margin-bottom: 4px; + font-weight: 500; + } + .message { + font-size: 14px; + line-height: 16px; + color: rgba(0, 0, 0, 0.76); + } + } + + .description { + margin-left: 8px; + min-width: 70px; + color: rgba(0, 0, 0, 0.54); + + .mark-read { + margin-bottom: 6px; + } + + .time { + font-size: 12px; + letter-spacing: 0.25px; + margin-right: 2px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/notification/notification.component.ts b/ui-ngx/src/app/shared/components/notification/notification.component.ts new file mode 100644 index 0000000000..5cf12edc7a --- /dev/null +++ b/ui-ngx/src/app/shared/components/notification/notification.component.ts @@ -0,0 +1,41 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// 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, EventEmitter, Input, Output } from '@angular/core'; +import { Notification } from '@shared/models/notification.models'; + +@Component({ + selector: 'tb-notification', + templateUrl: './notification.component.html', + styleUrls: ['./notification.component.scss'] +}) +export class NotificationComponent { + + @Input() + notification: Notification; + + @Output() + markAsRead = new EventEmitter(); + constructor() { + } + + markRead($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.markAsRead.next(this.notification.id.id); + } +} diff --git a/ui-ngx/src/app/shared/models/notification-ws.models.ts b/ui-ngx/src/app/shared/models/notification-ws.models.ts new file mode 100644 index 0000000000..f3224b8c36 --- /dev/null +++ b/ui-ngx/src/app/shared/models/notification-ws.models.ts @@ -0,0 +1,264 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// 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 { BehaviorSubject, ReplaySubject, Subject } from 'rxjs'; +import { CmdUpdate, CmdUpdateMsg, CmdUpdateType, WebsocketCmd } from '@shared/models/telemetry/telemetry.models'; +import { first, map } from 'rxjs/operators'; +import { NgZone } from '@angular/core'; +import { isDefinedAndNotNull } from '@core/utils'; +import { Notification } from '@shared/models/notification.models'; + +export class NotificationCountUpdate extends CmdUpdate { + totalUnreadCount: number; + + constructor(msg: NotificationCountUpdateMsg) { + super(msg); + this.totalUnreadCount = msg.totalUnreadCount; + } +} + +export class NotificationsUpdate extends CmdUpdate { + totalUnreadCount: number; + update?: Notification; + notifications?: Notification[]; + + constructor(msg: NotificationsUpdateMsg) { + super(msg); + this.totalUnreadCount = msg.totalUnreadCount; + this.update = msg.update; + this.notifications = msg.notifications; + } +} + +export interface NotificationWsService { + subscribe(subscriber: NotificationSubscriber); + + update(subscriber: NotificationSubscriber); + + unsubscribe(subscriber: NotificationSubscriber); +} + +export class NotificationSubscriber { + private notificationCountSubject = new ReplaySubject(1); + private notificationsSubject = new BehaviorSubject(null); + private reconnectSubject = new Subject(); + + public subscriptionCommands: Array; + public messageLimit = 10; + + public notificationCount$ = this.notificationCountSubject.asObservable().pipe(map(msg => msg.totalUnreadCount)); + public notifications$ = this.notificationsSubject.asObservable().pipe(map(msg => msg?.notifications || [])); + + public static createNotificationCountSubscription(notificationWsService: NotificationWsService, + zone: NgZone): NotificationSubscriber { + const subscriptionCommand = new UnreadCountSubCmd(); + const subscriber = new NotificationSubscriber(notificationWsService, zone); + subscriber.subscriptionCommands.push(subscriptionCommand); + return subscriber; + } + + public static createNotificationsSubscription(notificationWsService: NotificationWsService, + zone: NgZone, limit = 10): NotificationSubscriber { + const subscriptionCommand = new UnreadSubCmd(limit); + const subscriber = new NotificationSubscriber(notificationWsService, zone); + subscriber.messageLimit = limit; + subscriber.subscriptionCommands.push(subscriptionCommand); + return subscriber; + } + + public static createMarkAsReadCommand(notificationWsService: NotificationWsService, + ids: string[]): NotificationSubscriber { + const subscriptionCommand = new MarkAsReadCmd(ids); + const subscriber = new NotificationSubscriber(notificationWsService); + subscriber.subscriptionCommands.push(subscriptionCommand); + return subscriber; + } + + public static createMarkAllAsReadCommand(notificationWsService: NotificationWsService): NotificationSubscriber { + const subscriptionCommand = new MarkAllAsReadCmd(); + const subscriber = new NotificationSubscriber(notificationWsService); + subscriber.subscriptionCommands.push(subscriptionCommand); + return subscriber; + } + + constructor(private notificationWsService: NotificationWsService, private zone?: NgZone) { + this.subscriptionCommands = []; + } + + public subscribe() { + this.notificationWsService.subscribe(this); + } + + public update() { + this.notificationWsService.update(this); + } + + public unsubscribe() { + this.notificationWsService.unsubscribe(this); + this.complete(); + } + + public onReconnected() { + this.reconnectSubject.next(); + } + + public complete() { + this.notificationCountSubject.complete(); + this.notificationsSubject.complete(); + this.reconnectSubject.complete(); + } + + onNotificationCountUpdate(message: NotificationCountUpdate) { + if (this.zone) { + this.zone.run( + () => { + this.notificationCountSubject.next(message); + } + ); + } else { + this.notificationCountSubject.next(message); + } + } + + onNotificationsUpdate(message: NotificationsUpdate) { + this.notificationsSubject.asObservable().pipe( + first() + ).subscribe((value) => { + let saveMessage; + if (isDefinedAndNotNull(value) && message.update) { + const findIndex = value.notifications.findIndex(item => item.id.id === message.update.id.id); + if (findIndex !== -1) { + value.notifications.push(message.update); + value.notifications.sort((a, b) => b.createdTime - a.createdTime); + if (value.notifications.length > this.messageLimit) { + value.notifications.pop(); + } + } + saveMessage = value; + } else { + saveMessage = message; + } + if (this.zone) { + this.zone.run( + () => { + this.notificationsSubject.next(saveMessage); + this.notificationCountSubject.next(saveMessage); + } + ); + } else { + this.notificationsSubject.next(saveMessage); + this.notificationCountSubject.next(saveMessage); + } + }); + } +} + +export class UnreadCountSubCmd implements WebsocketCmd { + cmdId: number; +} + +export class UnreadSubCmd implements WebsocketCmd { + limit: number; + cmdId: number; + + constructor(limit = 10) { + this.limit = limit; + } +} + +export class UnsubscribeCmd implements WebsocketCmd { + cmdId: number; +} + +export class MarkAsReadCmd extends UnreadCountSubCmd { + notifications: string[]; + + constructor(ids: string[]) { + super(); + this.notifications = ids; + } +} + +export class MarkAllAsReadCmd implements WebsocketCmd { + cmdId: number; +} + +export interface NotificationCountUpdateMsg extends CmdUpdateMsg { + cmdUpdateType: CmdUpdateType.NOTIFICATIONS_COUNT; + totalUnreadCount: number; +} + +export interface NotificationsUpdateMsg extends CmdUpdateMsg { + cmdUpdateType: CmdUpdateType.NOTIFICATIONS; + totalUnreadCount: number; + update?: Notification; + notifications?: Notification[]; +} + +export type WebsocketNotificationMsg = NotificationCountUpdateMsg | NotificationsUpdateMsg; + +export function isNotificationCountUpdateMsg(message: WebsocketNotificationMsg): message is NotificationCountUpdateMsg { + const updateMsg = (message as CmdUpdateMsg); + return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.NOTIFICATIONS_COUNT; +} + +export function isNotificationsUpdateMsg(message: WebsocketNotificationMsg): message is NotificationsUpdateMsg { + const updateMsg = (message as CmdUpdateMsg); + return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.NOTIFICATIONS; +} + +export class NotificationPluginCmdsWrapper { + + constructor() { + this.unreadCountSubCmd = null; + this.unreadSubCmd = null; + this.unsubCmd = null; + this.markAsReadCmd = null; + this.markAllAsReadCmd = null; + } + + unreadCountSubCmd: UnreadCountSubCmd; + unreadSubCmd: UnreadSubCmd; + unsubCmd: UnsubscribeCmd; + markAsReadCmd: MarkAsReadCmd; + markAllAsReadCmd: MarkAllAsReadCmd; + + public hasCommands(): boolean { + return isDefinedAndNotNull(this.unreadCountSubCmd) || + isDefinedAndNotNull(this.unreadSubCmd) || + isDefinedAndNotNull(this.unsubCmd) || + isDefinedAndNotNull(this.markAsReadCmd) || + isDefinedAndNotNull(this.markAllAsReadCmd); + } + + public clear() { + this.unreadCountSubCmd = null; + this.unreadSubCmd = null; + this.unsubCmd = null; + this.markAsReadCmd = null; + this.markAllAsReadCmd = null; + } + + public preparePublishCommands(): NotificationPluginCmdsWrapper { + const preparedWrapper = new NotificationPluginCmdsWrapper(); + preparedWrapper.unreadCountSubCmd = this.unreadCountSubCmd || undefined; + preparedWrapper.unreadSubCmd = this.unreadSubCmd || undefined; + preparedWrapper.unsubCmd = this.unsubCmd || undefined; + preparedWrapper.markAsReadCmd = this.markAsReadCmd || undefined; + preparedWrapper.markAllAsReadCmd = this.markAllAsReadCmd || undefined; + return preparedWrapper; + } +} diff --git a/ui-ngx/src/app/shared/models/public-api.ts b/ui-ngx/src/app/shared/models/public-api.ts index 4c45802c2a..4ca0f86f7a 100644 --- a/ui-ngx/src/app/shared/models/public-api.ts +++ b/ui-ngx/src/app/shared/models/public-api.ts @@ -39,6 +39,7 @@ export * from './event.models'; export * from './login.models'; export * from './material.models'; export * from './notification.models'; +export * from './notification-ws.models'; export * from './oauth2.models'; export * from './queue.models'; export * from './relation.models'; diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts index 1cd44652a9..d3a3bf38f4 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -335,7 +335,9 @@ export interface SubscriptionUpdateMsg extends SubscriptionDataHolder { export enum CmdUpdateType { ENTITY_DATA = 'ENTITY_DATA', ALARM_DATA = 'ALARM_DATA', - COUNT_DATA = 'COUNT_DATA' + COUNT_DATA = 'COUNT_DATA', + NOTIFICATIONS_COUNT = 'NOTIFICATIONS_COUNT', + NOTIFICATIONS = 'NOTIFICATIONS' } export interface CmdUpdateMsg { diff --git a/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts b/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts new file mode 100644 index 0000000000..4ecafe3d10 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts @@ -0,0 +1,57 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// 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 { Inject, Pipe, PipeTransform } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +const intervals = { + years: 31536000, + months: 2592000, + weeks: 604800, + days: 86400, + hr: 3600, + min: 60, + sec: 1 +}; + +@Pipe({ + name: 'dateAgo' +}) +export class DateAgoPipe implements PipeTransform { + + constructor(@Inject(TranslateService) private translate: TranslateService) { + + } + + transform(value: number): string { + if (value) { + const seconds = Math.floor((+new Date() - +new Date(value)) / 1000); + if (seconds < 29) { // less than 30 seconds ago will show as 'Just now' + return this.translate.instant('timewindow.just-now'); + } + let counter; + // tslint:disable-next-line:forin + for (const i in intervals) { + counter = Math.floor(seconds / intervals[i]); + if (counter > 0) { + return this.translate.instant(`timewindow.${i}`, {[i]: counter}); + } + } + } + return value.toString(); + } + +} diff --git a/ui-ngx/src/app/shared/pipe/public-api.ts b/ui-ngx/src/app/shared/pipe/public-api.ts index dc810aaaf1..1b995059b6 100644 --- a/ui-ngx/src/app/shared/pipe/public-api.ts +++ b/ui-ngx/src/app/shared/pipe/public-api.ts @@ -14,6 +14,7 @@ /// limitations under the License. /// +export * from './date-ago.pipe'; export * from './enum-to-array.pipe'; export * from './highlight.pipe'; export * from './keyboard-shortcut.pipe'; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 8b184cab2f..255e0db710 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -25,6 +25,7 @@ import { NgxFlowchartModule } from 'ngx-flowchart'; import Flow from '@flowjs/flow.js'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCardModule } from '@angular/material/card'; @@ -170,6 +171,8 @@ import { PhoneInputComponent } from '@shared/components/phone-input.component'; import { CustomDateAdapter } from '@shared/adapter/custom-datatime-adapter'; import { CustomPaginatorIntl } from '@shared/services/custom-paginator-intl'; import { TbScriptLangComponent } from '@shared/components/script-lang.component'; +import { NotificationComponent } from '@shared/components/notification/notification.component'; +import { DateAgoPipe } from '@shared/pipe/date-ago.pipe'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -184,6 +187,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TruncatePipe, TbJsonPipe, FileSizePipe, + DateAgoPipe, SafePipe, { provide: FlowInjectionToken, @@ -285,6 +289,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TruncatePipe, TbJsonPipe, FileSizePipe, + DateAgoPipe, SafePipe, SelectableColumnsPipe, KeyboardShortcutPipe, @@ -300,12 +305,14 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ProtobufContentComponent, BranchAutocompleteComponent, PhoneInputComponent, - TbScriptLangComponent + TbScriptLangComponent, + NotificationComponent ], imports: [ CommonModule, RouterModule, TranslateModule, + MatBadgeModule, MatButtonModule, MatButtonToggleModule, MatCheckboxModule, @@ -418,6 +425,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) FabToolbarComponent, WidgetsBundleSelectComponent, ValueInputComponent, + MatBadgeModule, MatButtonModule, MatButtonToggleModule, MatCheckboxModule, @@ -491,6 +499,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TbJsonPipe, KeyboardShortcutPipe, FileSizePipe, + DateAgoPipe, SafePipe, SelectableColumnsPipe, RouterModule, @@ -506,7 +515,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ProtobufContentComponent, BranchAutocompleteComponent, PhoneInputComponent, - TbScriptLangComponent + TbScriptLangComponent, + NotificationComponent ] }) export class SharedModule { } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index cd78329026..ad567cc4ae 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2701,7 +2701,7 @@ "get-customer-id-from-originator": "Get Customer id from originator", "inbox": "Inbox", "management": "Notification management", - "mark-all-read": "Mark all read", + "mark-all-as-read": "Mark all as read", "mark-as-read": "Mark as read", "message": "Message", "message-required": "Message is required", @@ -2710,6 +2710,7 @@ "no-notification-request": "No notification request", "no-notification-templates": "No notification templates", "no-targets-notification": "No targets notification", + "notification": "Notification", "notification-center": "Notification center", "notification-target": "Notification target", "recipients-count": "{ count, plural, 1 {1 Recipient} other {# Recipients} }", @@ -2753,6 +2754,7 @@ "text": "Text", "type": "Type", "unread": "Unread", + "view-all": "View al;", "web-settings": "Web settings" }, "ota-update": { @@ -3465,10 +3467,16 @@ "days": "Days" }, "timewindow": { + "years": "{ years, plural, 1 { year } other {# years } }", + "months": "{ months, plural, 1 { month } other {# months } }", + "weeks": "{ weeks, plural, 1 { week } other {# weeks } }", "days": "{ days, plural, 1 { day } other {# days } }", "hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }", + "hr": "{{ hr }} hr", "minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }", + "min": "{{ min }} min", "seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }", + "sec": "{{ sec }} sec", "realtime": "Realtime", "history": "History", "last-prefix": "last", @@ -3478,7 +3486,8 @@ "last": "Last", "time-period": "Time period", "hide": "Hide", - "interval": "Interval" + "interval": "Interval", + "just-now": "Just now" }, "user": { "user": "User",