src/app/features/notification/notification.component.ts
Display Notification indicator for toolbar that opens into a list of notification events.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-notification |
| standalone | true |
| imports |
MatBadgeModule
FontAwesomeModule
MatMenu
MatButtonModule
MatMenuTrigger
MatMenuModule
MatTooltipModule
MatTabsModule
|
| styleUrls | ./notification.component.scss |
| templateUrl | ./notification.component.html |
| styleUrl | ./notification.component.scss |
MatBadgeModule
FontAwesomeModule
MatMenu
MatButtonModule
MatMenuTrigger
MatMenuModule
FormsModule
MatTooltipModule
MatTabsModule
NotificationItemComponent
RouterLink
OnInit
Properties |
|
Methods |
|
| Async deleteNotification | ||||||
deleteNotification(notification: NotificationEvent)
|
||||||
|
Deletes a user notification.
Parameters :
Returns :
any
|
| loadMore |
loadMore()
|
|
Load more notifications for the current tab.
Returns :
void
|
| Async markAllRead |
markAllRead()
|
|
Marks all notifications as read.
Returns :
Promise<void>
|
| Async notificationClicked | ||||||||||||||||
notificationClicked(notification: NotificationEvent, notificationListTrigger: MatMenuTrigger, event: NotificationEvent)
|
||||||||||||||||
|
Updates the read status of a selected notification. Handles notification events by redirecting the user to the corresponding action URL.
Parameters :
Returns :
any
|
| Async updateReadStatus | |||||||||
updateReadStatus(notifications: NotificationEvent[], newStatus: boolean)
|
|||||||||
|
Updates the read status for multiple notifications.
Parameters :
Returns :
any
|
| Readonly allNotifications |
Type : unknown
|
Default value : signal<NotificationEvent[]>([])
|
|
All notifications for the user |
| Protected Readonly closeOnlySubmenu |
Type : unknown
|
Default value : closeOnlySubmenu
|
| Readonly displayLimitAll |
Type : unknown
|
Default value : signal(this.PAGE_SIZE)
|
|
Current display limit for "All" tab |
| Readonly displayLimitUnread |
Type : unknown
|
Default value : signal(this.PAGE_SIZE)
|
|
Current display limit for "Unread" tab |
| Readonly hasMoreAllNotifications |
Type : unknown
|
Default value : computed(
() => this.allNotifications().length > this.displayLimitAll(),
)
|
|
Check if there are more notifications to load in "All" tab |
| hasNotificationConfig |
Type : unknown
|
Default value : false
|
|
whether an initial notification config exists for the user |
| Public selectedTab |
Type : number
|
Default value : 0
|
| Readonly unreadNotifications |
Type : unknown
|
Default value : signal<NotificationEvent[]>([])
|
|
Unread notifications for the user |
| Readonly visibleAllNotifications |
Type : unknown
|
Default value : computed(() =>
this.allNotifications().slice(0, this.displayLimitAll()),
)
|
|
Get notifications to display in "All" tab (limited) |
import {
Component,
computed,
inject,
OnInit,
signal,
ChangeDetectionStrategy,
} from "@angular/core";
import { Logging } from "app/core/logging/logging.service";
import { Subject, Subscription } from "rxjs";
import { MatBadgeModule } from "@angular/material/badge";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { MatMenu, MatMenuModule, MatMenuTrigger } from "@angular/material/menu";
import { MatButtonModule } from "@angular/material/button";
import { FormsModule } from "@angular/forms";
import { MatTooltipModule } from "@angular/material/tooltip";
import { NotificationEvent } from "./model/notification-event";
import { EntityMapperService } from "app/core/entity/entity-mapper/entity-mapper.service";
import { MatTabsModule } from "@angular/material/tabs";
import { NotificationItemComponent } from "./notification-item/notification-item.component";
import { SessionSubject } from "app/core/session/auth/session-info";
import { closeOnlySubmenu } from "./close-only-submenu";
import { Router, RouterLink } from "@angular/router";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { applyUpdate } from "../../core/entity/model/entity-update";
import { Entity } from "../../core/entity/model/entity";
import { EntityRegistry } from "app/core/entity/database-entity.decorator";
import { NotificationConfig } from "./model/notification-config";
import { DatabaseResolverService } from "../../core/database/database-resolver.service";
import { getEntityRuntimeRoute } from "../../core/entity/entity-config.service";
import { DatabaseException } from "../../core/database/pouchdb/pouch-database";
/**
* Display Notification indicator for toolbar
* that opens into a list of notification events.
*/
@UntilDestroy()
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-notification",
standalone: true,
imports: [
MatBadgeModule,
FontAwesomeModule,
MatMenu,
MatButtonModule,
MatMenuTrigger,
MatMenuModule,
FormsModule,
MatTooltipModule,
MatTabsModule,
NotificationItemComponent,
RouterLink,
],
templateUrl: "./notification.component.html",
styleUrl: "./notification.component.scss",
})
export class NotificationComponent implements OnInit {
private readonly notificationsSubject = new Subject<NotificationEvent[]>();
public selectedTab = 0;
protected readonly closeOnlySubmenu = closeOnlySubmenu;
/** whether an initial notification config exists for the user */
hasNotificationConfig = false;
/** Number of notifications to show initially and per "Load more" click */
private readonly PAGE_SIZE = 10;
/** All notifications for the user */
readonly allNotifications = signal<NotificationEvent[]>([]);
/** Unread notifications for the user */
readonly unreadNotifications = signal<NotificationEvent[]>([]);
/** Current display limit for "All" tab */
readonly displayLimitAll = signal(this.PAGE_SIZE);
/** Current display limit for "Unread" tab */
readonly displayLimitUnread = signal(this.PAGE_SIZE);
/** Get notifications to display in "All" tab (limited) */
readonly visibleAllNotifications = computed(() =>
this.allNotifications().slice(0, this.displayLimitAll()),
);
/** Get notifications to display in "Unread" tab (limited) */
readonly visibleUnreadNotifications = computed(() =>
this.unreadNotifications().slice(0, this.displayLimitUnread()),
);
/** Check if there are more notifications to load in "All" tab */
readonly hasMoreAllNotifications = computed(
() => this.allNotifications().length > this.displayLimitAll(),
);
/** Check if there are more notifications to load in "Unread" tab */
readonly hasMoreUnreadNotifications = computed(
() => this.unreadNotifications().length > this.displayLimitUnread(),
);
private readonly entityMapper = inject(EntityMapperService);
private readonly sessionInfo = inject(SessionSubject);
private readonly router = inject(Router);
private readonly entityRegistry = inject(EntityRegistry);
private readonly dbResolver = inject(DatabaseResolverService);
ngOnInit() {
this.notificationsSubject.subscribe((notifications) => {
this.filterUserNotifications(notifications);
});
this.loadAndProcessNotifications();
this.listenToEntityUpdates();
this.checkNotificationConfigStatus();
}
private async checkNotificationConfigStatus() {
const initial = await this.entityMapper
.load<NotificationConfig>(NotificationConfig, this.userId)
.then((doc) => !!doc)
.catch(() => false);
this.setNotificationConfigStatus(initial);
this.entityMapper
.receiveUpdates(NotificationConfig)
.pipe(untilDestroyed(this))
.subscribe((next) => this.setNotificationConfigStatus(!!next.entity));
}
private setNotificationConfigStatus(hasNotificationConfig: boolean) {
this.hasNotificationConfig = hasNotificationConfig;
if (hasNotificationConfig) {
// initialize DB (only now, as we know the user has notifications configured)
this.dbResolver.initializeNotificationsDatabaseForCurrentUser(
this.userId,
);
}
}
/**
* Get the logged-in user id
*/
private get userId(): string | undefined {
return this.sessionInfo.value?.id;
}
/**
* Loads all notifications and processes them to update the list and unread count.
*/
private async loadAndProcessNotifications() {
let notifications: NotificationEvent[];
try {
notifications =
await this.entityMapper.loadType<NotificationEvent>(NotificationEvent);
} catch (err) {
if (err instanceof DatabaseException && (err as any).status === 404) {
// DB doesn't exist yet — it's created only once the first notification event is written
Logging.debug("Notifications database not yet available", err);
return;
}
throw err;
}
this.notificationsSubject.next(notifications);
}
private updateSubscription: Subscription;
private listenToEntityUpdates() {
if (!this.updateSubscription) {
this.updateSubscription = this.entityMapper
.receiveUpdates(NotificationEvent)
.pipe(untilDestroyed(this))
.subscribe((next) => {
this.notificationsSubject.next(
applyUpdate(this.allNotifications(), next),
);
});
}
}
/**
* Filters notifications based on the sender and read status.
*/
private filterUserNotifications(notifications: NotificationEvent[]) {
this.allNotifications.set(
notifications.sort(
(notificationA, notificationB) =>
notificationB.created.at.getTime() -
notificationA.created.at.getTime(),
),
);
this.unreadNotifications.set(
notifications.filter((notification) => !notification.readStatus),
);
}
/**
* Load more notifications for the current tab.
*/
loadMore(): void {
if (this.selectedTab === 0) {
this.displayLimitAll.update((limit) => limit + this.PAGE_SIZE);
} else {
this.displayLimitUnread.update((limit) => limit + this.PAGE_SIZE);
}
}
/**
* Marks all notifications as read.
*/
async markAllRead(): Promise<void> {
const unreadNotifications = this.allNotifications().filter(
(notification) => !notification.readStatus,
);
await this.updateReadStatus(unreadNotifications, true);
}
/**
* Updates the read status for multiple notifications.
*/
async updateReadStatus(
notifications: NotificationEvent[],
newStatus: boolean,
) {
for (const notification of notifications) {
notification.readStatus = newStatus;
await this.entityMapper.save(notification);
}
this.filterUserNotifications(this.allNotifications());
}
/**
* Deletes a user notification.
*/
async deleteNotification(notification: NotificationEvent) {
await this.entityMapper.remove(notification);
}
private generateNotificationActionURL(
notification: NotificationEvent,
): string {
if (!notification.context) return notification.actionURL;
let actionURL = "";
switch (notification.notificationType) {
case "entity_change":
actionURL = this.generateEntityUrl(notification);
break;
default:
actionURL = notification.actionURL;
}
return actionURL;
}
private normalizeEntityId(entityType: string, entityId: string): string {
return Entity.extractEntityIdFromId(
Entity.createPrefixedId(entityType, entityId),
);
}
private generateEntityUrl(notification: NotificationEvent): string {
let url = "";
const entityCtr = this.entityRegistry.get(notification.context.entityType);
if (entityCtr) {
url = getEntityRuntimeRoute(entityCtr);
if (notification.context.entityId) {
url += `/${this.normalizeEntityId(
notification.context.entityType,
notification.context.entityId,
)}`;
}
}
return url;
}
/**
* Updates the read status of a selected notification.
* Handles notification events by redirecting the user to the corresponding action URL.
* @param {NotificationEvent} notification - The notification event containing the action URL.
*/
async notificationClicked(
notification: NotificationEvent,
notificationListTrigger: MatMenuTrigger,
event: NotificationEvent,
) {
await this.updateReadStatus([notification], true);
const actionURL = this.generateNotificationActionURL(notification);
if (!actionURL) return;
await this.router.navigate([actionURL]);
// Close the notification menu after clicking a notification
this.closeOnlySubmenu(
notificationListTrigger,
event as unknown as MouseEvent,
);
}
}
<button
mat-icon-button
matTooltip="Notifications"
i18n-matTooltip="notifications toolbar icon tooltip"
[matMenuTriggerFor]="notificationList"
#notificationListTrigger="matMenuTrigger"
>
<span
[matBadge]="
unreadNotifications()?.length > 0 ? unreadNotifications()?.length : null
"
matBadgeColor="accent"
>
<fa-icon class="white" icon="bell"></fa-icon>
</span>
</button>
<mat-menu #notificationList>
<div
class="notification-panel flex-column"
(click)="$event.stopPropagation()"
>
<!-- stopPropagation above to avoid closing the panel upon action button clicks -->
<div class="notification-list-header">
<div
class="flex-row justify-space-between align-center notification-panel-header"
>
<h2 class="notification-title" i18n>Notifications</h2>
<button
mat-icon-button
[matMenuTriggerFor]="settingsMenu"
#settingsMenuTrigger="matMenuTrigger"
>
<fa-icon icon="ellipsis"></fa-icon>
</button>
</div>
<!-- Tabs -->
<mat-tab-group [(selectedIndex)]="selectedTab">
<mat-tab label="All" i18n-label></mat-tab>
<mat-tab label="Unread" i18n-label></mat-tab>
</mat-tab-group>
</div>
@if (!hasNotificationConfig) {
<button
mat-raised-button
color="accent"
class="margin-regular"
[routerLink]="['/user-account']"
[queryParams]="{ tabIndex: 1 }"
i18n
>
Activate Notifications
</button>
}
<div class="flex-grow flex-column gap-small notification-list-body">
@for (
notification of selectedTab === 0
? visibleAllNotifications()
: visibleUnreadNotifications();
track notification.getId()
) {
<app-notification-item
[notification]="notification"
(deleteClick)="deleteNotification(notification)"
(readStatusChange)="updateReadStatus([notification], $event)"
(notificationClick)="
notificationClicked(notification, notificationListTrigger, $event)
"
></app-notification-item>
} @empty {
<div class="flex-column no-notification-message">
<fa-icon
class="no-notification-icon"
[icon]="['far', 'bell']"
></fa-icon>
<span class="no-notification-text" i18n
>You have no notifications</span
>
</div>
}
<!-- Load More Button -->
@if (
selectedTab === 0
? hasMoreAllNotifications()
: hasMoreUnreadNotifications()
) {
<button
mat-raised-button
color="accent"
class="margin-regular"
(click)="loadMore()"
i18n
>
Load more
</button>
}
</div>
</div>
</mat-menu>
<!-- New mat-menu for ellipsis -->
<mat-menu #settingsMenu>
<div
class="notification-panel"
(click)="closeOnlySubmenu(settingsMenuTrigger, $event)"
>
<button mat-menu-item (click)="markAllRead()">
<fa-icon icon="check" class="standard-icon-with-text"></fa-icon>
<span
matTooltip="Mark all as read"
i18n-matTooltip="Tooltip for notification settings button"
matTooltipPosition="above"
i18n
>Mark all as read</span
>
</button>
<button
mat-menu-item
[routerLink]="['/user-account']"
[queryParams]="{ tabIndex: 1 }"
(click)="closeOnlySubmenu(notificationListTrigger, $event)"
>
<fa-icon icon="gear" class="standard-icon-with-text"></fa-icon>
<span
matTooltip="Notification Settings"
i18n-matTooltip="Tooltip for notification settings button"
matTooltipPosition="above"
i18n
>Notification Settings</span
>
</button>
</div>
</mat-menu>
./notification.component.scss
@use "variables/colors";
.notification-list-header {
position: sticky;
top: 0px;
background-color: colors.$background;
}
.notification-title {
font-weight: 700;
margin: 0px;
}
.no-notification-text {
color: colors.$inactive;
}
.notification-panel-header {
padding: 8px 10px;
}
.notification-list-body {
overflow-y: auto;
}
.notification-panel {
width: auto;
max-height: 90vh;
}
.no-notification-message {
text-align: center;
padding: 24px;
}
.no-notification-icon {
font-size: 56px;
margin-bottom: 20px;
color: colors.$inactive;
}
::ng-deep .mat-mdc-menu-panel {
min-width: 310px !important;
.mat-mdc-menu-item-text {
white-space: nowrap !important;
overflow: hidden;
text-overflow: ellipsis;
}
}