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

Description

UI for current user to configure individual notification settings.

Example

Metadata

Relationships

Depends on

Index

Properties
Methods

Constructor

constructor()

Methods

addNewNotificationRule
addNewNotificationRule()
Returns : void
Async confirmRemoveNotificationRule
confirmRemoveNotificationRule(index: number)
Parameters :
Name Type Optional
index number No
Returns : unknown
Async saveSettings
saveSettings()
Returns : any
testNotification
testNotification()

Sends a test push-notification.

Returns : void
toggleEmailChannel
toggleEmailChannel(event: MatSlideToggleChange)
Parameters :
Name Type Optional
event MatSlideToggleChange No
Returns : void
Async togglePushNotifications
togglePushNotifications(event: MatSlideToggleChange)
Parameters :
Name Type Optional
event MatSlideToggleChange No
Returns : any
updateNotificationRule
updateNotificationRule(notificationRule: NotificationRule, updatedRule: NotificationRule)
Parameters :
Name Type Optional
notificationRule NotificationRule No
updatedRule NotificationRule No
Returns : void

Properties

Readonly accountEmail
Type : unknown
Default value : toSignal(this.sessionInfo.pipe(map((s) => s?.email)))
isPushNotificationEnabled
Type : unknown
Default value : linkedSignal( () => this.isDeviceRegisteredResource.value() ?? false, )
notificationConfig
Type : unknown
Default value : linkedSignal( () => this.notificationConfigResource.value() ?? null, )
Protected Readonly notificationService
Type : unknown
Default value : inject(NotificationService)
Protected Readonly unsavedChanges
Type : unknown
Default value : inject(UnsavedChangesService)
import {
  ChangeDetectionStrategy,
  Component,
  DestroyRef,
  inject,
  linkedSignal,
  resource,
  untracked,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { map } from "rxjs";
import {
  MatSlideToggle,
  MatSlideToggleChange,
} from "@angular/material/slide-toggle";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { Logging } from "app/core/logging/logging.service";
import { MatButtonModule } from "@angular/material/button";
import { MatFormFieldModule } from "@angular/material/form-field";
import { HelpButtonComponent } from "app/core/common-components/help-button/help-button.component";
import { ConfirmationDialogService } from "app/core/common-components/confirmation-dialog/confirmation-dialog.service";
import { EntityMapperService } from "app/core/entity/entity-mapper/entity-mapper.service";
import {
  NotificationConfig,
  NotificationRule,
} from "app/features/notification/model/notification-config";
import { SessionSubject } from "app/core/session/auth/session-info";
import { NotificationRuleComponent } from "../notification-rule/notification-rule.component";
import { MatTooltip } from "@angular/material/tooltip";
import { CdkAccordionModule } from "@angular/cdk/accordion";
import { NotificationService } from "../notification.service";
import { MatAccordion } from "@angular/material/expansion";
import { AlertService } from "../../../core/alerts/alert.service";
import { PLACEHOLDERS } from "../../../core/entity/schema/entity-schema-field";
import { CurrentUserSubject } from "../../../core/session/current-user-subject";
import { Config } from "../../../core/config/config";
import { FeatureDisabledInfoComponent } from "../../../core/common-components/feature-disabled-info/feature-disabled-info.component";
import { UnsavedChangesService } from "../../../core/entity-details/form/unsaved-changes.service";

/**
 * UI for current user to configure individual notification settings.
 */
@Component({
  selector: "app-notification-settings",
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    MatSlideToggle,
    FontAwesomeModule,
    MatFormFieldModule,
    MatButtonModule,
    HelpButtonComponent,
    NotificationRuleComponent,
    MatTooltip,
    CdkAccordionModule,
    MatAccordion,
    FeatureDisabledInfoComponent,
  ],
  templateUrl: "./notification-settings.component.html",
  styleUrl: "./notification-settings.component.scss",
})
export class NotificationSettingsComponent {
  private readonly entityMapper = inject(EntityMapperService);
  private readonly sessionInfo = inject(SessionSubject);
  private readonly userEntity = inject(CurrentUserSubject);
  private readonly confirmationDialog = inject(ConfirmationDialogService);
  protected readonly notificationService = inject(NotificationService);
  private readonly alertService = inject(AlertService);
  protected readonly unsavedChanges = inject(UnsavedChangesService);

  constructor() {
    // clear this component's unsaved-changes state when it is destroyed
    inject(DestroyRef).onDestroy(() =>
      this.unsavedChanges.setUnsavedChanges(this, false),
    );
  }

  readonly accountEmail = toSignal(this.sessionInfo.pipe(map((s) => s?.email)));

  private readonly notificationConfigResource = resource({
    loader: () => untracked(() => this.loadNotificationConfig()),
  });
  notificationConfig = linkedSignal(
    () => this.notificationConfigResource.value() ?? null,
  );

  private readonly isDeviceRegisteredResource = resource({
    loader: async () =>
      untracked(() =>
        this.notificationService.hasNotificationPermissionGranted(),
      ) && (await this.notificationService.isDeviceRegistered()),
  });
  isPushNotificationEnabled = linkedSignal(
    () => this.isDeviceRegisteredResource.value() ?? false,
  );

  /**
   * Get the logged-in user id
   */
  private get userId() {
    return this.sessionInfo.value?.id;
  }

  private async loadNotificationConfig() {
    let notificationConfig: NotificationConfig;
    try {
      notificationConfig =
        await this.notificationService.loadNotificationConfig(this.userId);
    } catch (err) {
      if (err.status === 404) {
        notificationConfig = await this.createNewNotificationConfig();
      } else {
        Logging.warn(err);
      }
    }

    return notificationConfig;
  }

  private async createNewNotificationConfig(): Promise<NotificationConfig> {
    if (!this.notificationService.isNotificationServerEnabled()) {
      // do not create a new config if the API is not enabled
      return;
    }

    let config: NotificationConfig;

    try {
      // try to load template from database
      let templateRules = (
        await this.entityMapper.load<
          Config<{ notificationRules: NotificationRule[] }>
        >(Config, NotificationConfig.TEMPLATE_ENTITY_ID)
      )?.data?.["notificationRules"];

      // replace user entity in template rules
      templateRules = JSON.parse(
        JSON.stringify(templateRules).replace(
          PLACEHOLDERS.CURRENT_USER,
          this.userEntity.value?.getId(),
        ),
      );

      config = new NotificationConfig(this.userId);
      config.notificationRules = templateRules;
    } catch (err) {
      Logging.debug("No NotificationConfig template found");

      // use fixed default config as fallback
      config = generateDefaultNotificationConfig(
        this.userId,
        this.userEntity.value?.getId(),
      );
    }

    const saved = await this.saveNotificationConfig(config);
    if (!saved) return;
    this.alertService.addInfo(
      $localize`Initial notification settings created and saved.`,
    );

    return config;
  }

  toggleEmailChannel(event: MatSlideToggleChange) {
    this.notificationConfig.update((config) => {
      const clone = Object.assign(
        Object.create(Object.getPrototypeOf(config)),
        config,
      );
      clone.channels = { ...config.channels, email: event.checked };
      return clone;
    });
    this.unsavedChanges.setUnsavedChanges(this, true);
  }

  async togglePushNotifications(event: MatSlideToggleChange) {
    let enabled = event.checked;
    if (enabled) {
      this.notificationService.registerDevice();
    } else {
      this.notificationService.unregisterDevice();
    }
    this.isPushNotificationEnabled.set(enabled);

    // we do not add "push" channel to this.notificationConfig.channels
  }

  private async saveNotificationConfig(
    config: NotificationConfig,
  ): Promise<boolean> {
    try {
      await this.entityMapper.save(config);
      return true;
    } catch (err) {
      Logging.error(err.message);
      return false;
    }
  }

  addNewNotificationRule() {
    const newRule: NotificationRule = {
      notificationType: "entity_change",
      entityType: undefined,
      channels: this.notificationConfig().channels, // by default, use the global channels
      conditions: {},
      enabled: true,
    };

    this.notificationConfig.update((config) => {
      const clone = Object.assign(
        Object.create(Object.getPrototypeOf(config)),
        config,
      );
      clone.notificationRules = [...(config.notificationRules ?? []), newRule];
      return clone;
    });
    this.unsavedChanges.setUnsavedChanges(this, true);
  }

  updateNotificationRule(
    notificationRule: NotificationRule,
    updatedRule: NotificationRule,
  ) {
    this.notificationConfig.update((config) => {
      const clone = Object.assign(
        Object.create(Object.getPrototypeOf(config)),
        config,
      );
      clone.notificationRules = (config.notificationRules ?? []).map((rule) =>
        rule === notificationRule ? { ...rule, ...updatedRule } : rule,
      );
      return clone;
    });
    this.unsavedChanges.setUnsavedChanges(this, true);
  }

  async saveSettings() {
    const saved = await this.saveNotificationConfig(this.notificationConfig());
    if (!saved) return;
    this.unsavedChanges.setUnsavedChanges(this, false);
    this.alertService.addInfo($localize`Notification settings saved.`);
  }

  async confirmRemoveNotificationRule(index: number) {
    const confirmed = await this.confirmationDialog.getConfirmation(
      $localize`Delete notification rule`,
      $localize`Are you sure you want to remove this notification rule?`,
    );
    if (confirmed) {
      this.notificationConfig.update((config) => {
        const clone = Object.assign(
          Object.create(Object.getPrototypeOf(config)),
          config,
        );
        clone.notificationRules = (config.notificationRules ?? []).filter(
          (_, i) => i !== index,
        );
        return clone;
      });
      const saved = await this.saveNotificationConfig(
        this.notificationConfig(),
      );
      if (!saved) return false;
      this.unsavedChanges.setUnsavedChanges(this, false);
      return true;
    }
    return false;
  }

  /**
   * Sends a test push-notification.
   */
  testNotification() {
    this.notificationService.testNotification().catch((reason) => {
      Logging.error("Could not send test notification", {
        reason: reason.message,
      });
    });
  }
}

function generateDefaultNotificationConfig(userId: string, userEntity: string) {
  userEntity = String(userEntity); // ensure that even "undefined" is added as a string so that the structure of conditions remains

  const config = new NotificationConfig(userId);
  config.notificationRules = [
    {
      label: $localize`:Default notification rule label:Tasks assigned to me`,
      notificationType: "entity_change",
      entityType: "Todo",
      changeType: ["created", "updated"],
      conditions: { assignedTo: { $elemMatch: userEntity } },
      enabled: !!userEntity,
    },
    {
      label: $localize`:Default notification rule label:Notes involving me`,
      notificationType: "entity_change",
      entityType: "Note",
      changeType: ["created", "updated"],
      conditions: { authors: { $elemMatch: userEntity } },
      enabled: false,
    },
  ];
  return config;
}
<div class="padding-regular">
  <div class="flex-row align-center justify-space-between full-width">
    <h1 class="no-margin" i18n>Notifications Settings</h1>
    @if (notificationConfig()) {
      <button
        mat-raised-button
        color="primary"
        (click)="saveSettings()"
        [disabled]="!unsavedChanges.pending()"
        i18n
      >
        Save
      </button>
    }
  </div>
  <div i18n>
    Notifications alert you to important system events, such as new
    registrations via public forms or tasks assigned to you. They appear in the
    toolbar and can be sent via email or push notifications.
  </div>

  <app-feature-disabled-info
    i18n-featureName
    featureName="Notification API"
    [featureEnabled]="notificationService.isNotificationServerEnabled()"
  ></app-feature-disabled-info>

  @if (notificationConfig()) {
    <!--
      Notification Rules
    -->
    <div class="margin-top-large">
      <h3 class="no-margin" i18n>
        <strong>What notifications you receive</strong>
      </h3>
      <div i18n class="margin-bottom-regular">
        Define rules for events that you want to get notifications for. You can
        add as many different rules as you want and disable some temporarily to
        pause these notifications.
      </div>

      <mat-accordion>
        @for (
          notificationRule of notificationConfig()?.notificationRules;
          track $index;
          let index = $index
        ) {
          <app-notification-rule
            [value]="notificationRule"
            (valueChange)="updateNotificationRule(notificationRule, $event)"
            (removeNotificationRule)="confirmRemoveNotificationRule(index)"
          ></app-notification-rule>
        }
      </mat-accordion>

      <div class="flex-row justify-content-center margin-top-regular">
        <button
          mat-stroked-button
          class="add-new-rule-button"
          color="accent"
          (click)="addNewNotificationRule()"
          matTooltip="Define another type of notifications for your user account"
          i18n-matTooltip
        >
          <fa-icon
            aria-hidden="true"
            icon="plus-circle"
            class="standard-icon-with-text"
          ></fa-icon>
          <span i18n>Add new notification rule</span>
        </button>
      </div>
    </div>

    <!--
      Notification Channels
    -->
    <div class="margin-top-large">
      <div>
        <h3 class="no-margin flex-row align-center">
          <strong i18n>Where you receive notifications</strong>
        </h3>
        <div i18n>
          Notifications are always visible through the bell icon in the toolbar
          at the top of the application. You can enable other ways to get
          notifications sent to you below.
        </div>
      </div>

      @if (!notificationService.isPushNotificationSupported()) {
        <div class="margin-top-large feature-disabled-box">
          <h2 i18n>
            Push notifications are currently not supported by your Browser.
          </h2>
          <p i18n>
            Please contact your system administrator for more information.
          </p>
          <p i18n>
            If you using iOS, you need to add the app to your Homescreen. You
            can use the "Install App" button in the menu to do this.
          </p>
        </div>
      }

      <div class="receive-notifications-container flex-column gap-large">
        <div class="flex-column">
          <div class="flex-row gap-small">
            <fa-icon icon="window-maximize"></fa-icon>
            <p i18n>Browser</p>
          </div>

          <div class="flex-row gap-regular">
            <div class="flex-row gap-regular align-center">
              <mat-slide-toggle
                checked
                disabled
                class="indented-item"
                i18n-matTooltip
                matTooltip="Always enabled"
              ></mat-slide-toggle>
              <span i18n>Notification Center (in app)</span>
              <app-help-button
                text="Click the bell icon in the top toolbar to access notifications any time. You can find all messages, including read ones, there."
                i18n-text
              ></app-help-button>
            </div>
          </div>

          <div class="flex-row gap-regular align-center">
            <mat-slide-toggle
              (change)="togglePushNotifications($event)"
              [checked]="isPushNotificationEnabled()"
              class="indented-item"
              [disabled]="!notificationService.isPushNotificationSupported()"
            />
            <span i18n>Push notifications</span>
            <app-help-button
              text="A notification on your system (e.g. a normal Android smartphone notification). You see these alerts even when your app is not opened at the moment."
              i18n-text
            ></app-help-button>

            <button
              mat-stroked-button
              color="accent"
              (click)="testNotification()"
              [disabled]="!isPushNotificationEnabled()"
              matTooltip="Show a sample notification (only available if push notifications are enabled)"
              i18n-matTooltip
            >
              <fa-icon
                icon="paper-plane"
                class="standard-icon-with-text"
              ></fa-icon>
              <span i18n>Test Notification</span>
            </button>
          </div>
        </div>

        <div>
          <div class="flex-row gap-small">
            <fa-icon icon="envelope"></fa-icon>
            <p i18n>Email</p>
          </div>

          <div class="flex-row gap-regular align-center">
            <mat-slide-toggle
              (change)="toggleEmailChannel($event)"
              [checked]="notificationConfig()?.channels?.['email']"
              [disabled]="!notificationService.isEmailNotificationEnabled()"
              class="indented-item"
              [matTooltipDisabled]="
                notificationService.isEmailNotificationEnabled()
              "
              matTooltip="Email notifications are not enabled in this system. Please contact your system administrator for more information."
              i18n-matTooltip
            />
            <span i18n>Email</span>
            <app-help-button
              text="Receive an email notification for each matching event, even when you are not using the app."
              i18n-text
            ></app-help-button>
          </div>
          @if (accountEmail()) {
            <div class="indented-item mat-hint" i18n>
              Notifications will be sent to your account email
              {{ accountEmail() }}
            </div>
          }
        </div>
      </div>
    </div>
  }
</div>

./notification-settings.component.scss

@use "variables/colors";
@use "variables/sizes";
@use "@angular/material/core/style/elevation" as mat-elevation;

.panel-wrapper {
  @include mat-elevation.elevation(2);
  border-radius: 10px;
  padding: 16px;
}

.receive-notifications-container {
  @extend .panel-wrapper;
  background-color: colors.$background;
  margin-top: sizes.$regular;
}

.no-margin {
  margin: 0;
}

.add-new-rule-button {
  width: 50%;
  border-radius: 20px;
  padding: 22px;
  background-color: aliceblue;
}

.coming-soon-label {
  color: colors.$inactive;
  margin-left: 10px;
  font-style: italic;
}

.text-badge {
  font-size: 12px;
  font-weight: bold;
  margin-left: 10px;
  border-radius: 25px;
  padding-inline: 15px;
  border: 1px solid;
}

.indented-item {
  margin-left: sizes.$large;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""