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