Browse Source

UI: Add ws to show notification

pull/7980/head
Vladyslav_Prykhodko 4 years ago
parent
commit
5dcd11cd82
  1. 336
      ui-ngx/src/app/core/ws/notification-websocket.service.ts
  2. 20
      ui-ngx/src/app/modules/home/components/notification/notification-bell.component.html
  3. 86
      ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts
  4. 40
      ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html
  5. 93
      ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts
  6. 1
      ui-ngx/src/app/modules/home/home.component.html
  7. 6
      ui-ngx/src/app/modules/home/home.module.ts
  8. 2
      ui-ngx/src/app/modules/home/pages/notification-center/notification-table/inbox-table-config.ts
  9. 30
      ui-ngx/src/app/shared/components/notification/notification.component.html
  10. 53
      ui-ngx/src/app/shared/components/notification/notification.component.scss
  11. 41
      ui-ngx/src/app/shared/components/notification/notification.component.ts
  12. 264
      ui-ngx/src/app/shared/models/notification-ws.models.ts
  13. 1
      ui-ngx/src/app/shared/models/public-api.ts
  14. 4
      ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts
  15. 57
      ui-ngx/src/app/shared/pipe/date-ago.pipe.ts
  16. 1
      ui-ngx/src/app/shared/pipe/public-api.ts
  17. 14
      ui-ngx/src/app/shared/shared.module.ts
  18. 13
      ui-ngx/src/assets/locale/locale.constant-en_US.json

336
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<number, NotificationSubscriber>();
reconnectSubscribers = new Set<NotificationSubscriber>();
cmdsWrapper = new NotificationPluginCmdsWrapper();
notificationUri: string;
dataStream: WebSocketSubject<NotificationPluginCmdsWrapper | WebsocketNotificationMsg>;
constructor(private store: Store<AppState>,
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'
}));
}
}

20
ui-ngx/src/app/modules/home/components/notification/notification-bell.component.html

@ -0,0 +1,20 @@
<!--
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.
-->
<button mat-icon-button #showNotificationButton (click)="showNotification($event, showNotificationButton)">
<mat-icon class="material-icons" [matBadge]="(count$ | async)" [matBadgeHidden]="!(count$ | async)" matBadgeColor="warn" matBadgeSize="small">notifications</mat-icon>
</button>

86
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<number>;
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;
}
}
}

40
ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html

@ -0,0 +1,40 @@
<!--
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.
-->
<section fxLayout="row" fxLayoutAlign="space-between center" style="margin-bottom: 4px">
<div style="font-weight: 500; letter-spacing: .25px" translate>notification.notification</div>
<button mat-button color="primary"
[disabled]="!(notifications$ | async).length"
(click)="markAsAllRead($event)">
{{ 'notification.mark-all-as-read' | translate }}
</button>
</section>
<mat-divider></mat-divider>
<section style="min-height: 100px; overflow: auto">
<div *ngFor="let notification of (notifications$ | async); let last = last">
<tb-notification [notification]="notification" (markAsRead)="markAsRead($event)"></tb-notification>
<mat-divider *ngIf="!last" style="margin-bottom: 4px"></mat-divider>
</div>
</section>
<div *ngIf="(notifications$ | async).length">
<mat-divider></mat-divider>
<section fxLayoutAlign="center center">
<button fxFlex mat-button color="primary" (click)="viewAll($event)">
{{ 'notification.view-all' | translate }}
</button>
</section>
</div>

93
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<Notification[]>;
constructor(protected store: Store<AppState>,
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(() => {});
}
}

1
ui-ngx/src/app/modules/home/home.component.html

@ -70,6 +70,7 @@
mat-icon-button fxHide.lt-md (click)="toggleFullscreen()"> mat-icon-button fxHide.lt-md (click)="toggleFullscreen()">
<mat-icon class="material-icons">{{ isFullscreen() ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon> <mat-icon class="material-icons">{{ isFullscreen() ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button> </button>
<tb-notification-bell></tb-notification-bell>
<tb-user-menu [displayUserInfo]="!displaySearchMode()"></tb-user-menu> <tb-user-menu [displayUserInfo]="!displaySearchMode()"></tb-user-menu>
</mat-toolbar> </mat-toolbar>
<mat-progress-bar color="warn" style="z-index: 10; margin-bottom: -4px; width: 100%;" mode="indeterminate" <mat-progress-bar color="warn" style="z-index: 10; margin-bottom: -4px; width: 100%;" mode="indeterminate"

6
ui-ngx/src/app/modules/home/home.module.ts

@ -23,6 +23,8 @@ import { SharedModule } from '@app/shared/shared.module';
import { MenuLinkComponent } from '@modules/home/menu/menu-link.component'; import { MenuLinkComponent } from '@modules/home/menu/menu-link.component';
import { MenuToggleComponent } from '@modules/home/menu/menu-toggle.component'; import { MenuToggleComponent } from '@modules/home/menu/menu-toggle.component';
import { SideMenuComponent } from '@modules/home/menu/side-menu.component'; import { SideMenuComponent } from '@modules/home/menu/side-menu.component';
import { NotificationBellComponent } from '@home/components/notification/notification-bell.component';
import { ShowNotificationPopoverComponent } from '@home/components/notification/show-notification-popover.component';
@NgModule({ @NgModule({
declarations: declarations:
@ -30,7 +32,9 @@ import { SideMenuComponent } from '@modules/home/menu/side-menu.component';
HomeComponent, HomeComponent,
MenuLinkComponent, MenuLinkComponent,
MenuToggleComponent, MenuToggleComponent,
SideMenuComponent SideMenuComponent,
NotificationBellComponent,
ShowNotificationPopoverComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

2
ui-ngx/src/app/modules/home/pages/notification-center/notification-table/inbox-table-config.ts

@ -59,7 +59,7 @@ export class InboxTableConfig extends EntityTableConfig<Notification> {
this.headerComponent = InboxTableHeaderComponent; this.headerComponent = InboxTableHeaderComponent;
this.headerActionDescriptors = [{ this.headerActionDescriptors = [{
name: this.translate.instant('notification.mark-all-read'), name: this.translate.instant('notification.mark-all-as-read'),
icon: 'done_all', icon: 'done_all',
isEnabled: () => true, isEnabled: () => true,
onAction: $event => this.markAllRead($event) onAction: $event => this.markAllRead($event)

30
ui-ngx/src/app/shared/components/notification/notification.component.html

@ -0,0 +1,30 @@
<!--
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.
-->
<section fxLayout="row" fxLayoutAlign="space-between start">
<mat-icon>account_circle</mat-icon>
<div class="content" fxFlex>
<div class="title">{{ notification.subject }}</div>
<div class="message">{{ notification.text }}</div>
</div>
<div class="description" fxLayout="column" fxLayoutAlign="start end">
<button mat-icon-button class="tb-mat-28 mark-read" (click)="markRead($event)">
<mat-icon>check_circle_outline</mat-icon>
</button>
<span class="time">{{ notification.createdTime | dateAgo }}</span>
</div>
</section>

53
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;
}
}
}

41
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<string>();
constructor() {
}
markRead($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.markAsRead.next(this.notification.id.id);
}
}

264
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<NotificationCountUpdate>(1);
private notificationsSubject = new BehaviorSubject<NotificationsUpdate>(null);
private reconnectSubject = new Subject();
public subscriptionCommands: Array<WebsocketCmd>;
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;
}
}

1
ui-ngx/src/app/shared/models/public-api.ts

@ -39,6 +39,7 @@ export * from './event.models';
export * from './login.models'; export * from './login.models';
export * from './material.models'; export * from './material.models';
export * from './notification.models'; export * from './notification.models';
export * from './notification-ws.models';
export * from './oauth2.models'; export * from './oauth2.models';
export * from './queue.models'; export * from './queue.models';
export * from './relation.models'; export * from './relation.models';

4
ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts

@ -335,7 +335,9 @@ export interface SubscriptionUpdateMsg extends SubscriptionDataHolder {
export enum CmdUpdateType { export enum CmdUpdateType {
ENTITY_DATA = 'ENTITY_DATA', ENTITY_DATA = 'ENTITY_DATA',
ALARM_DATA = 'ALARM_DATA', ALARM_DATA = 'ALARM_DATA',
COUNT_DATA = 'COUNT_DATA' COUNT_DATA = 'COUNT_DATA',
NOTIFICATIONS_COUNT = 'NOTIFICATIONS_COUNT',
NOTIFICATIONS = 'NOTIFICATIONS'
} }
export interface CmdUpdateMsg { export interface CmdUpdateMsg {

57
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();
}
}

1
ui-ngx/src/app/shared/pipe/public-api.ts

@ -14,6 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
export * from './date-ago.pipe';
export * from './enum-to-array.pipe'; export * from './enum-to-array.pipe';
export * from './highlight.pipe'; export * from './highlight.pipe';
export * from './keyboard-shortcut.pipe'; export * from './keyboard-shortcut.pipe';

14
ui-ngx/src/app/shared/shared.module.ts

@ -25,6 +25,7 @@ import { NgxFlowchartModule } from 'ngx-flowchart';
import Flow from '@flowjs/flow.js'; import Flow from '@flowjs/flow.js';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card'; 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 { CustomDateAdapter } from '@shared/adapter/custom-datatime-adapter';
import { CustomPaginatorIntl } from '@shared/services/custom-paginator-intl'; import { CustomPaginatorIntl } from '@shared/services/custom-paginator-intl';
import { TbScriptLangComponent } from '@shared/components/script-lang.component'; 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) { export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService; return markedOptionsService;
@ -184,6 +187,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TruncatePipe, TruncatePipe,
TbJsonPipe, TbJsonPipe,
FileSizePipe, FileSizePipe,
DateAgoPipe,
SafePipe, SafePipe,
{ {
provide: FlowInjectionToken, provide: FlowInjectionToken,
@ -285,6 +289,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TruncatePipe, TruncatePipe,
TbJsonPipe, TbJsonPipe,
FileSizePipe, FileSizePipe,
DateAgoPipe,
SafePipe, SafePipe,
SelectableColumnsPipe, SelectableColumnsPipe,
KeyboardShortcutPipe, KeyboardShortcutPipe,
@ -300,12 +305,14 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ProtobufContentComponent, ProtobufContentComponent,
BranchAutocompleteComponent, BranchAutocompleteComponent,
PhoneInputComponent, PhoneInputComponent,
TbScriptLangComponent TbScriptLangComponent,
NotificationComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
RouterModule, RouterModule,
TranslateModule, TranslateModule,
MatBadgeModule,
MatButtonModule, MatButtonModule,
MatButtonToggleModule, MatButtonToggleModule,
MatCheckboxModule, MatCheckboxModule,
@ -418,6 +425,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
FabToolbarComponent, FabToolbarComponent,
WidgetsBundleSelectComponent, WidgetsBundleSelectComponent,
ValueInputComponent, ValueInputComponent,
MatBadgeModule,
MatButtonModule, MatButtonModule,
MatButtonToggleModule, MatButtonToggleModule,
MatCheckboxModule, MatCheckboxModule,
@ -491,6 +499,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TbJsonPipe, TbJsonPipe,
KeyboardShortcutPipe, KeyboardShortcutPipe,
FileSizePipe, FileSizePipe,
DateAgoPipe,
SafePipe, SafePipe,
SelectableColumnsPipe, SelectableColumnsPipe,
RouterModule, RouterModule,
@ -506,7 +515,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ProtobufContentComponent, ProtobufContentComponent,
BranchAutocompleteComponent, BranchAutocompleteComponent,
PhoneInputComponent, PhoneInputComponent,
TbScriptLangComponent TbScriptLangComponent,
NotificationComponent
] ]
}) })
export class SharedModule { } export class SharedModule { }

13
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -2701,7 +2701,7 @@
"get-customer-id-from-originator": "Get Customer id from originator", "get-customer-id-from-originator": "Get Customer id from originator",
"inbox": "Inbox", "inbox": "Inbox",
"management": "Notification management", "management": "Notification management",
"mark-all-read": "Mark all read", "mark-all-as-read": "Mark all as read",
"mark-as-read": "Mark as read", "mark-as-read": "Mark as read",
"message": "Message", "message": "Message",
"message-required": "Message is required", "message-required": "Message is required",
@ -2710,6 +2710,7 @@
"no-notification-request": "No notification request", "no-notification-request": "No notification request",
"no-notification-templates": "No notification templates", "no-notification-templates": "No notification templates",
"no-targets-notification": "No targets notification", "no-targets-notification": "No targets notification",
"notification": "Notification",
"notification-center": "Notification center", "notification-center": "Notification center",
"notification-target": "Notification target", "notification-target": "Notification target",
"recipients-count": "{ count, plural, 1 {1 Recipient} other {# Recipients} }", "recipients-count": "{ count, plural, 1 {1 Recipient} other {# Recipients} }",
@ -2753,6 +2754,7 @@
"text": "Text", "text": "Text",
"type": "Type", "type": "Type",
"unread": "Unread", "unread": "Unread",
"view-all": "View al;",
"web-settings": "Web settings" "web-settings": "Web settings"
}, },
"ota-update": { "ota-update": {
@ -3465,10 +3467,16 @@
"days": "Days" "days": "Days"
}, },
"timewindow": { "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 } }", "days": "{ days, plural, 1 { day } other {# days } }",
"hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }", "hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }",
"hr": "{{ hr }} hr",
"minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }", "minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }",
"min": "{{ min }} min",
"seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }", "seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }",
"sec": "{{ sec }} sec",
"realtime": "Realtime", "realtime": "Realtime",
"history": "History", "history": "History",
"last-prefix": "last", "last-prefix": "last",
@ -3478,7 +3486,8 @@
"last": "Last", "last": "Last",
"time-period": "Time period", "time-period": "Time period",
"hide": "Hide", "hide": "Hide",
"interval": "Interval" "interval": "Interval",
"just-now": "Just now"
}, },
"user": { "user": {
"user": "User", "user": "User",

Loading…
Cancel
Save