src/app/features/notification/notification-settings/notification-settings.component.ts
UI for current user to configure individual notification settings.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-notification-settings |
| standalone | true |
| imports |
MatSlideToggle
FontAwesomeModule
MatFormFieldModule
MatButtonModule
MatTooltip
CdkAccordionModule
MatAccordion
|
| styleUrls | ./notification-settings.component.scss |
| templateUrl | ./notification-settings.component.html |
| styleUrl | ./notification-settings.component.scss |
MatSlideToggle
FontAwesomeModule
MatFormFieldModule
MatButtonModule
HelpButtonComponent
NotificationRuleComponent
MatTooltip
CdkAccordionModule
MatAccordion
FeatureDisabledInfoComponent
Properties |
|
Methods |
constructor()
|
| addNewNotificationRule |
addNewNotificationRule()
|
|
Returns :
void
|
| Async confirmRemoveNotificationRule | ||||||
confirmRemoveNotificationRule(index: number)
|
||||||
|
Parameters :
Returns :
unknown
|
| Async saveSettings |
saveSettings()
|
|
Returns :
any
|
| testNotification |
testNotification()
|
|
Sends a test push-notification.
Returns :
void
|
| toggleEmailChannel | ||||||
toggleEmailChannel(event: MatSlideToggleChange)
|
||||||
|
Parameters :
Returns :
void
|
| Async togglePushNotifications | ||||||
togglePushNotifications(event: MatSlideToggleChange)
|
||||||
|
Parameters :
Returns :
any
|
| updateNotificationRule | |||||||||
updateNotificationRule(notificationRule: NotificationRule, updatedRule: NotificationRule)
|
|||||||||
|
Parameters :
Returns :
void
|
| 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;
}