18 changed files with 1055 additions and 7 deletions
@ -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' |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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(() => {}); |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
Loading…
Reference in new issue