pwa notifications

This commit is contained in:
Bastian Wagner
2026-06-29 14:52:33 +02:00
parent 8fdee223c0
commit a033c50c25
18 changed files with 780 additions and 26 deletions

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'listify-shell-v1';
const CACHE_NAME = 'listify-shell-v2';
const SHELL_ASSETS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg'];
self.addEventListener('install', (event) => {
@@ -15,11 +15,7 @@ self.addEventListener('activate', (event) => {
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
)
.then(() => self.clients.claim()),
);
@@ -63,3 +59,45 @@ self.addEventListener('fetch', (event) => {
}),
);
});
self.addEventListener('push', (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
payload = {};
}
const title = payload.title || 'Listify Tasks';
const options = {
body: payload.body || 'Du hast offene Tasks.',
tag: payload.tag || 'listify-task-digest',
icon: '/pwa-icon.svg',
badge: '/pwa-icon.svg',
data: {
url: payload.url || '/tasks',
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = event.notification.data?.url || '/tasks';
const absoluteUrl = new URL(targetUrl, self.location.origin).href;
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if (client.url === absoluteUrl && 'focus' in client) {
return client.focus();
}
}
return self.clients.openWindow(absoluteUrl);
}),
);
});

View File

@@ -54,6 +54,50 @@
<span>Speichert...</span>
</div>
}
<div class="push-row">
<div>
<h3>PWA-Benachrichtigungen</h3>
<p>
Gleiche Tasks und Zeiten wie die Task-Mail. Die App braucht dafuer eine
Browser-Erlaubnis.
</p>
</div>
@if (!taskPush.supported()) {
<span class="push-state">Nicht unterstuetzt</span>
} @else if (taskPush.permission() === 'granted') {
<button
mat-stroked-button
type="button"
[disabled]="taskPush.saving()"
(click)="disablePushNotifications()"
>
@if (taskPush.saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">notifications_off</mat-icon>
}
Deaktivieren
</button>
} @else {
<button
mat-stroked-button
type="button"
[disabled]="
taskPush.saving() || (auth.user()?.taskDigestPreference ?? 'both') === 'none'
"
(click)="enablePushNotifications()"
>
@if (taskPush.saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">notifications_active</mat-icon>
}
Aktivieren
</button>
}
</div>
</section>
</mat-card-content>

View File

@@ -80,6 +80,33 @@
font-size: 0.9rem;
}
.push-row {
display: grid;
gap: 0.7rem;
padding-top: 0.8rem;
border-top: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
}
.push-row h3,
.push-row p {
margin: 0;
}
.push-row h3 {
font-size: 0.95rem;
font-weight: 600;
}
.push-row p,
.push-state {
color: var(--mat-sys-on-surface-variant);
line-height: 1.45;
}
.push-row button {
justify-self: start;
}
mat-card-actions {
flex-wrap: wrap;
gap: 0.5rem;

View File

@@ -11,6 +11,7 @@ import { getAuthErrorMessage } from '../auth/error-message';
import { AuthService } from '../auth/auth.service';
import { TaskDigestPreference } from '../auth/auth.models';
import { OnboardingService } from '../onboarding/onboarding.service';
import { TaskPushService } from '../tasks/task-push.service';
@Component({
selector: 'app-account',
@@ -28,6 +29,7 @@ import { OnboardingService } from '../onboarding/onboarding.service';
export class AccountComponent {
protected readonly auth = inject(AuthService);
protected readonly onboarding = inject(OnboardingService);
protected readonly taskPush = inject(TaskPushService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
protected savingTaskDigestPreference = false;
@@ -71,6 +73,9 @@ export class AccountComponent {
this.snackBar.open('Task-Mail-Einstellung gespeichert.', 'OK', {
duration: 3000,
});
if (preference === 'none') {
void this.taskPush.disableCurrentSubscription();
}
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
@@ -80,6 +85,36 @@ export class AccountComponent {
});
}
async enablePushNotifications(): Promise<void> {
await this.taskPush.enable();
if (this.taskPush.errorMessage()) {
this.snackBar.open(this.taskPush.errorMessage()!, 'OK', {
duration: 5000,
});
return;
}
this.snackBar.open('PWA-Benachrichtigungen aktiviert.', 'OK', {
duration: 3000,
});
}
async disablePushNotifications(): Promise<void> {
await this.taskPush.disableCurrentSubscription();
if (this.taskPush.errorMessage()) {
this.snackBar.open(this.taskPush.errorMessage()!, 'OK', {
duration: 5000,
});
return;
}
this.snackBar.open('PWA-Benachrichtigungen deaktiviert.', 'OK', {
duration: 3000,
});
}
logout(): void {
this.auth.logout();
void this.router.navigateByUrl('/login');

View File

@@ -3,7 +3,6 @@ import { authGuard } from './auth/auth.guard';
import { unauthGuard } from './auth/unauth.guard';
import { LoginComponent } from './auth/login/login.component';
import { RegisterComponent } from './auth/register/register.component';
import { AccountComponent } from './account/account.component';
import { VerifyEmailComponent } from './auth/verify-email/verify-email.component';
import { ListDetailComponent } from './lists/list-detail/list-detail.component';
import { ListsComponent } from './lists/lists.component';
@@ -54,6 +53,11 @@ export const routes: Routes = [
),
canActivate: [authGuard],
},
{ path: 'account', component: AccountComponent, canActivate: [authGuard] },
{
path: 'account',
loadComponent: () =>
import('./account/account.component').then((module) => module.AccountComponent),
canActivate: [authGuard],
},
{ path: '**', redirectTo: 'login' },
];

View File

@@ -0,0 +1,118 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
interface PushPublicKeyResponse {
enabled: boolean;
publicKey?: string;
}
@Injectable({ providedIn: 'root' })
export class TaskPushService {
private readonly http = inject(HttpClient);
private readonly apiUrl = '/api/tasks/push';
readonly saving = signal(false);
readonly errorMessage = signal<string | null>(null);
supported(): boolean {
return (
typeof window !== 'undefined' &&
'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window
);
}
permission(): NotificationPermission | 'unsupported' {
if (!this.supported()) {
return 'unsupported';
}
return Notification.permission;
}
async enable(): Promise<void> {
this.errorMessage.set(null);
if (!this.supported()) {
this.errorMessage.set('Push-Benachrichtigungen werden nicht unterstuetzt.');
return;
}
this.saving.set(true);
try {
const publicKeyResponse = await firstValueFrom(
this.http.get<PushPublicKeyResponse>(`${this.apiUrl}/public-key`),
);
if (!publicKeyResponse.enabled || !publicKeyResponse.publicKey) {
this.errorMessage.set('Push-Benachrichtigungen sind serverseitig nicht konfiguriert.');
return;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
this.errorMessage.set('Benachrichtigungen wurden nicht erlaubt.');
return;
}
const registration = await navigator.serviceWorker.ready;
const existingSubscription = await registration.pushManager.getSubscription();
const subscription =
existingSubscription ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToArrayBuffer(publicKeyResponse.publicKey),
}));
await firstValueFrom(this.http.post(`${this.apiUrl}/subscriptions`, subscription.toJSON()));
} catch {
this.errorMessage.set('Push-Benachrichtigungen konnten nicht aktiviert werden.');
} finally {
this.saving.set(false);
}
}
async disableCurrentSubscription(): Promise<void> {
this.errorMessage.set(null);
if (!this.supported()) {
return;
}
this.saving.set(true);
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await firstValueFrom(
this.http.delete(`${this.apiUrl}/subscriptions`, {
body: { endpoint: subscription.endpoint },
}),
);
await subscription.unsubscribe();
}
} catch {
this.errorMessage.set('Push-Benachrichtigungen konnten nicht deaktiviert werden.');
} finally {
this.saving.set(false);
}
}
private urlBase64ToArrayBuffer(value: string): ArrayBuffer {
const padding = '='.repeat((4 - (value.length % 4)) % 4);
const base64 = `${value}${padding}`.replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let index = 0; index < rawData.length; index += 1) {
outputArray[index] = rawData.charCodeAt(index);
}
return outputArray.buffer;
}
}