import { inject, Injectable, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Router } from '@angular/router'; import { SwPush } from '@angular/service-worker'; import { firstValueFrom, take } from 'rxjs'; interface NotificationData { url?: string; } interface ClickedNotification extends NotificationOptions { data?: NotificationData; } export interface NotificationPayload { title: string; body: string; icon?: string; badge?: string; data?: NotificationData; tag?: string; requireInteraction?: boolean; } interface VapidKeyResponse { data: string; } interface SubscribePayload { endpoint: string; key_p256dh: string; key_auth: string; } export interface PushMessagePayload { notification?: { title?: string; body?: string; icon?: string; url?: string; }; data?: Record; } @Injectable({ providedIn: 'root', }) export class PushNotificationService { private http = inject(HttpClient); private swPush = inject(SwPush); private router = inject(Router); public notificationPermission = signal('default'); public isSubscribed = signal(false); constructor() { this.initializeNotifications(); } private initializeNotifications(): void { if ('Notification' in window) { this.notificationPermission.set(Notification.permission); } if (this.swPush.isEnabled) { this.swPush.subscription.subscribe((subscription) => { this.isSubscribed.set(!!subscription); }); this.swPush.notificationClicks.subscribe(({ action, notification }) => { this.handleNotificationClick(action, notification); }); } } public async requestPermission(): Promise { if (!('Notification' in window)) { console.warn('This browser does not support notifications'); return 'denied'; } if (this.notificationPermission() === 'granted') { return 'granted'; } const permission = await Notification.requestPermission(); this.notificationPermission.set(permission); if (permission === 'granted') { await this.subscribeToNotifications(); } return permission; } public async ensurePushSubscription(): Promise { if (!('Notification' in window)) { console.warn('This browser does not support notifications'); return; } const currentPermission = this.notificationPermission(); if (currentPermission === 'denied') { return; } if (currentPermission === 'default') { await this.requestPermission(); return; } if (currentPermission === 'granted') { await this.subscribeToNotifications(); } } public async subscribeToNotifications(): Promise { if (!this.swPush.isEnabled) { console.warn('Service worker is not enabled'); return; } try { const existingSubscription = await firstValueFrom(this.swPush.subscription.pipe(take(1))); if (existingSubscription) { await this.sendSubscriptionToBackend(existingSubscription); this.isSubscribed.set(true); return; } const vapidPublicKey = await this.fetchVapidPublicKey(); if (!vapidPublicKey) { console.error('Could not fetch VAPID public key from backend'); return; } const subscription = await this.swPush.requestSubscription({ serverPublicKey: vapidPublicKey, }); await this.sendSubscriptionToBackend(subscription); this.isSubscribed.set(true); } catch (error) { console.error('Failed to subscribe to push notifications:', error); this.isSubscribed.set(false); } } public async unsubscribe(): Promise { if (!this.swPush.isEnabled) { return; } try { await this.swPush.unsubscribe(); this.isSubscribed.set(false); } catch (error) { console.error('Failed to unsubscribe from push notifications:', error); } } public async showNotification(payload: NotificationPayload): Promise { if (this.notificationPermission() !== 'granted') { console.warn('Notification permission not granted'); return; } if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { const registration = await navigator.serviceWorker.ready; const options: NotificationOptions = { body: payload.body, icon: payload.icon || '/icons/icon-192x192.png', badge: payload.badge || '/icons/icon-72x72.png', data: payload.data, tag: payload.tag, requireInteraction: payload.requireInteraction, }; if ('vibrate' in navigator) { (options as NotificationOptions & { vibrate?: number[] }).vibrate = [200, 100, 200]; } await registration.showNotification(payload.title, options); } } private async fetchVapidPublicKey(): Promise { try { const response = await firstValueFrom(this.http.get('web_push/public_key')); return response?.data || null; } catch (error) { console.error('Failed to fetch VAPID public key:', error); return null; } } private arrayBufferToBase64Url(buffer: ArrayBuffer | null): string { if (!buffer) return ''; const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } private async sendSubscriptionToBackend(subscription: PushSubscription): Promise { const payload: SubscribePayload = { endpoint: subscription.endpoint, key_p256dh: this.arrayBufferToBase64Url(subscription.getKey('p256dh')), key_auth: this.arrayBufferToBase64Url(subscription.getKey('auth')), }; await firstValueFrom(this.http.post('web_push/subscribe', payload)); } private handleNotificationClick(action: string, notification: ClickedNotification): void { const url = notification.data?.url; if (url) { if (url.startsWith('/')) { this.router.navigateByUrl(url); } else { window.location.href = url; } } } public get hasPermission(): boolean { return this.notificationPermission() === 'granted'; } public onPushMessage(callback: (payload: PushMessagePayload) => void): void { if (this.swPush.isEnabled) { this.swPush.messages.subscribe((message) => { callback(message as PushMessagePayload); }); } } }