src/app/features/notification/notification.component.ts

Description

Display Notification indicator for toolbar that opens into a list of notification events.

Implements

OnInit

Example

Metadata

Relationships

Used by

Depends on

Index

Properties
Methods

Methods

Async deleteNotification
deleteNotification(notification: NotificationEvent)

Deletes a user notification.

Parameters :
Name Type Optional
notification NotificationEvent No
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 :
Name Type Optional Description
notification NotificationEvent No
  • The notification event containing the action URL.
notificationListTrigger MatMenuTrigger No
event NotificationEvent No
Returns : any
Async updateReadStatus
updateReadStatus(notifications: NotificationEvent[], newStatus: boolean)

Updates the read status for multiple notifications.

Parameters :
Name Type Optional
notifications NotificationEvent[] No
newStatus boolean No
Returns : any

Properties

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

Readonly hasMoreUnreadNotifications
Type : unknown
Default value : computed( () => this.unreadNotifications().length > this.displayLimitUnread(), )

Check if there are more notifications to load in "Unread" 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)

Readonly visibleUnreadNotifications
Type : unknown
Default value : computed(() => this.unreadNotifications().slice(0, this.displayLimitUnread()), )

Get notifications to display in "Unread" 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;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""