pwa notifications
This commit is contained in:
@@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
118
listify-client/src/app/tasks/task-push.service.ts
Normal file
118
listify-client/src/app/tasks/task-push.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user