From 32f25df31c3f0fe27a9337e1f2f7122141b42489 Mon Sep 17 00:00:00 2001 From: Marc Schmidt Date: Sun, 23 Jun 2024 01:03:55 +0200 Subject: [PATCH] refactor: move web push related stuff into WebPushService Also added check for OculusBrowser and removed subscription from server after logout. Refs: #85 --- .../src/app/components/app/app.component.ts | 56 ++------ .../notification-prompt-dialog.component.ts | 29 ++-- .../user-settings/user-settings.component.ts | 49 +++---- src/client/src/app/services/auth.service.ts | 3 + .../src/app/services/web-push.service.ts | 131 ++++++++++++++++++ 5 files changed, 167 insertions(+), 101 deletions(-) create mode 100644 src/client/src/app/services/web-push.service.ts diff --git a/src/client/src/app/components/app/app.component.ts b/src/client/src/app/components/app/app.component.ts index 9bf38d2..89d12e2 100644 --- a/src/client/src/app/components/app/app.component.ts +++ b/src/client/src/app/components/app/app.component.ts @@ -1,22 +1,17 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, viewChild } from '@angular/core'; -import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; -import { Router, RouterOutlet } from '@angular/router'; -import { SwPush } from '@angular/service-worker'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterOutlet } from '@angular/router'; import { ConfirmationService, MessageService, PrimeNGConfig } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { ToastModule } from 'primeng/toast'; -import { combineLatest, filter, first, pairwise, startWith } from 'rxjs'; import { FooterComponent } from './footer/footer.component'; import { MenuComponent } from './menu/menu.component'; import { NotificationPromptDialogComponent } from './notification-prompt-dialog/notification-prompt-dialog.component'; -import { NotificationsService } from '../../api/services'; import { environment } from '../../environments/environment'; import { AuthService } from '../../services/auth.service'; -import { getHasRejectedPush } from '../../services/storage'; -import { TranslateService } from '../../services/translate.service'; -import { arrayBufferToBase64 } from '../../utils/buffer.utils'; +import { WebPushService } from '../../services/web-push.service'; @Component({ selector: 'app-root', @@ -37,10 +32,7 @@ import { arrayBufferToBase64 } from '../../utils/buffer.utils'; }) export class AppComponent { private readonly _authService = inject(AuthService); - private readonly _translateService = inject(TranslateService); - private readonly _swPush = inject(SwPush); - private readonly _router = inject(Router); - private readonly _notificationsService = inject(NotificationsService); + private readonly _webPushService = inject(WebPushService); private readonly _notificationPromptDialog = viewChild.required( NotificationPromptDialogComponent @@ -53,41 +45,9 @@ export class AppComponent { const primengConfig = inject(PrimeNGConfig); primengConfig.ripple = true; - if (this._swPush.isEnabled) { - if (!getHasRejectedPush()) { - combineLatest([toObservable(this.isLoggedIn), this._swPush.subscription]) - .pipe( - filter(([isLoggedIn, subscription]) => isLoggedIn && !subscription), - first(), - takeUntilDestroyed() - ) - .subscribe(() => this._notificationPromptDialog().open()); - } - - combineLatest([ - toObservable(this._authService.user), - toObservable(this._translateService.language), - this._swPush.subscription, - ]) - .pipe(startWith([undefined, undefined, undefined]), pairwise(), takeUntilDestroyed()) - .subscribe(([[_oldUser, _oldLang, oldSub], [newUser, newLang, newSub]]) => { - if (newSub && newLang && newUser && newUser.id !== 'admin') { - // Update subscription if subscription changes or language changes - this._notificationsService.subscribeToNotifications({ - body: { - lang: newLang, - endpoint: newSub.endpoint, - p256DH: arrayBufferToBase64(newSub.getKey('p256dh')), - auth: arrayBufferToBase64(newSub.getKey('auth')), - }, - }); - } else if (oldSub && !newSub) { - // Unsubscribe if subscription is removed - this._notificationsService.unsubscribeFromNotifications({ - body: { endpoint: oldSub.endpoint }, - }); - } - }); - } + this._webPushService.init(); + this._webPushService.onPromptNotification + .pipe(takeUntilDestroyed()) + .subscribe(() => this._notificationPromptDialog().open()); } } diff --git a/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.ts b/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.ts index 6c00533..04b762b 100644 --- a/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.ts +++ b/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.ts @@ -1,12 +1,10 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; -import { SwPush } from '@angular/service-worker'; import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; -import { first } from 'rxjs'; import { setHasRejectedPush } from '../../../services/storage'; import { TranslateService } from '../../../services/translate.service'; -import { WellKnownService } from '../../../services/well-known.service'; +import { WebPushService } from '../../../services/web-push.service'; @Component({ selector: 'app-notification-prompt-dialog', @@ -16,8 +14,7 @@ import { WellKnownService } from '../../../services/well-known.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class NotificationPromptDialogComponent { - private readonly _swPush = inject(SwPush); - private readonly _wellKnownService = inject(WellKnownService); + private readonly _webPushService = inject(WebPushService); protected readonly translations = inject(TranslateService).translations; protected readonly visible = signal(false); @@ -27,22 +24,14 @@ export class NotificationPromptDialogComponent { this.visible.set(true); } - protected onAccept() { + protected async onAccept() { this.isLoading.set(true); - Notification.requestPermission().then(permission => { - if (permission === 'granted') { - this._wellKnownService.wellKnown$.pipe(first()).subscribe(({ vapidPublicKey }) => { - this._swPush.requestSubscription({ serverPublicKey: vapidPublicKey }).finally(() => { - this.isLoading.set(false); - this.visible.set(false); - }); - }); - } else { - setHasRejectedPush(true); - this.isLoading.set(false); - this.visible.set(false); - } - }); + try { + await this._webPushService.enable(); + } finally { + this.isLoading.set(false); + this.visible.set(false); + } } protected onReject() { diff --git a/src/client/src/app/components/user-settings/user-settings.component.ts b/src/client/src/app/components/user-settings/user-settings.component.ts index c6621a5..7e8930c 100644 --- a/src/client/src/app/components/user-settings/user-settings.component.ts +++ b/src/client/src/app/components/user-settings/user-settings.component.ts @@ -1,8 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; -import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; -import { SwPush } from '@angular/service-worker'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { MessageService } from 'primeng/api'; @@ -13,7 +12,7 @@ import { FloatLabelModule } from 'primeng/floatlabel'; import { InputSwitchModule } from 'primeng/inputswitch'; import { MessagesModule } from 'primeng/messages'; import { ProgressSpinnerModule } from 'primeng/progressspinner'; -import { filter, first, map, Subject } from 'rxjs'; +import { filter, map, Subject } from 'rxjs'; import { SavedFadingMessageComponent } from '../+common/saved-fading-message.component'; import { isActionBusy, hasActionFailed } from '../../+state/action-state'; @@ -28,9 +27,8 @@ import { UserSettings } from '../../models/parsed-models'; import { AuthService } from '../../services/auth.service'; import { Theme, ThemeService } from '../../services/theme.service'; import { TranslateService } from '../../services/translate.service'; -import { WellKnownService } from '../../services/well-known.service'; +import { WebPushService } from '../../services/web-push.service'; import { selectSignal } from '../../utils/ngrx.utils'; -import { chainSignals } from '../../utils/signal.utils'; type LanguageOption = { lang: string | null; @@ -74,26 +72,18 @@ export class UserSettingsComponent { private readonly _translateService = inject(TranslateService); private readonly _themeService = inject(ThemeService); private readonly _authService = inject(AuthService); - private readonly _wellKnownService = inject(WellKnownService); private readonly _actions$ = inject(Actions); - private readonly _swPush = inject(SwPush); private readonly _messageService = inject(MessageService); + private readonly _webPushService = inject(WebPushService); private readonly _loadActionState = selectSignal(selectUserSettingsActionState('load')); private readonly _updateActionState = selectSignal(selectUserSettingsActionState('update')); - private readonly _notificationsGranted = signal( - 'Notification' in window ? Notification.permission === 'granted' : false - ); - protected readonly notificationsPossible = 'Notification' in window && this._swPush.isEnabled; + protected readonly notificationsPossible = this._webPushService.notificationsSupported; protected readonly translations = this._translateService.translations; protected readonly resetNgModel = new Subject(); protected readonly settings = selectSignal(selectUserSettings); - protected readonly notificationsEnabled = this.notificationsPossible - ? chainSignals(toSignal(this._swPush.subscription.pipe(map(x => !!x))), hasPushSub => - computed(() => hasPushSub() && this._notificationsGranted()) - ) - : signal(false); + protected readonly notificationsEnabled = this._webPushService.notificationsEnabled; protected readonly languageOptions = computed(() => [ { @@ -176,21 +166,14 @@ export class UserSettingsComponent { this._store.dispatch(updateUserSettingsAction(changes)); } - protected toggleNotifications(enabled: boolean) { + protected async toggleNotifications(enabled: boolean) { if (!this.notificationsPossible) return; this.isUpdatingPushSubscription.set(true); - if (enabled) { - Notification.requestPermission().then(permission => { - this._notificationsGranted.set(permission === 'granted'); - if (permission === 'granted') { - this._wellKnownService.wellKnown$.pipe(first()).subscribe(({ vapidPublicKey }) => { - this._swPush.requestSubscription({ serverPublicKey: vapidPublicKey }).finally(() => { - this.isUpdatingPushSubscription.set(false); - }); - }); - } else { + try { + if (enabled) { + const success = await this._webPushService.enable(); + if (!success) { this.resetNgModel.next(); - this.isUpdatingPushSubscription.set(false); this._messageService.add({ severity: 'error', summary: this.translations.settings_notifications_errors_notGranted_title(), @@ -198,11 +181,11 @@ export class UserSettingsComponent { sticky: true, }); } - }); - } else { - this._swPush.unsubscribe().finally(() => { - this.isUpdatingPushSubscription.set(false); - }); + } else { + await this._webPushService.disable(); + } + } finally { + this.isUpdatingPushSubscription.set(false); } } diff --git a/src/client/src/app/services/auth.service.ts b/src/client/src/app/services/auth.service.ts index 8f95a69..6de791a 100644 --- a/src/client/src/app/services/auth.service.ts +++ b/src/client/src/app/services/auth.service.ts @@ -19,6 +19,7 @@ import { setAuthTokenInfo, setLoginToken, } from './storage'; +import { WebPushService } from './web-push.service'; import { AuthenticationService } from '../api/services'; import { environment } from '../environments/environment'; import { assertBody } from '../utils/http.utils'; @@ -29,6 +30,7 @@ export type SignInResult = 'success' | 'invalid-token' | 'error'; export class AuthService implements OnDestroy { private readonly _api = inject(AuthenticationService); private readonly _router = inject(Router); + private readonly _webPushService = inject(WebPushService); private readonly _token = signal(undefined); @@ -111,6 +113,7 @@ export class AuthService implements OnDestroy { public async signOut() { if (!environment.authenticationRequired) return; + await this._webPushService.disable(true); setLoginToken(null); this._token.set(null); diff --git a/src/client/src/app/services/web-push.service.ts b/src/client/src/app/services/web-push.service.ts new file mode 100644 index 0000000..370fec1 --- /dev/null +++ b/src/client/src/app/services/web-push.service.ts @@ -0,0 +1,131 @@ +import { computed, DestroyRef, EventEmitter, inject, Injectable, signal } from '@angular/core'; +import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { SwPush } from '@angular/service-worker'; +import { combineLatest, filter, first, startWith, pairwise, firstValueFrom, map } from 'rxjs'; + +import { AuthService } from './auth.service'; +import { getHasRejectedPush, setHasRejectedPush } from './storage'; +import { TranslateService } from './translate.service'; +import { WellKnownService } from './well-known.service'; +import { NotificationsService } from '../api/services'; +import { arrayBufferToBase64 } from '../utils/buffer.utils'; +import { chainSignals } from '../utils/signal.utils'; + +const Notification = 'Notification' in window ? window.Notification : null; + +@Injectable({ providedIn: 'root' }) +export class WebPushService { + private readonly _authService = inject(AuthService); + private readonly _translateService = inject(TranslateService); + private readonly _swPush = inject(SwPush); + private readonly _notificationsService = inject(NotificationsService); + private readonly _wellKnownService = inject(WellKnownService); + private readonly _destroyRef = inject(DestroyRef); + + private readonly _subscription = toSignal(this._swPush.subscription); + + public readonly onPromptNotification = new EventEmitter(); + public readonly notificationsPermission = signal(Notification?.permission ?? 'denied'); + public readonly notificationsSupported = + !!Notification && this._swPush.isEnabled && !/OculusBrowser/.test(navigator.userAgent); + public readonly notificationsEnabled = this.notificationsSupported + ? chainSignals(toSignal(this._swPush.subscription.pipe(map(x => !!x))), hasPushSub => + computed(() => hasPushSub() && this.notificationsPermission() === 'granted') + ) + : signal(false); + + public init(): void { + if (!this.notificationsSupported) return; + + if (!getHasRejectedPush()) { + combineLatest([ + toObservable(this._authService.isAuthorized), + this._swPush.subscription, + toObservable(this.notificationsPermission), + ]) + .pipe( + filter( + ([isLoggedIn, subscription, permission]) => + isLoggedIn && !subscription && permission !== 'denied' + ), + first(), + takeUntilDestroyed(this._destroyRef) + ) + .subscribe(() => this.onPromptNotification.emit()); + } + + combineLatest([ + toObservable(this._authService.user), + toObservable(this._translateService.language), + this._swPush.subscription, + ]) + .pipe( + startWith([undefined, undefined, undefined]), + pairwise(), + takeUntilDestroyed(this._destroyRef) + ) + .subscribe(([[_oldUser, _oldLang, oldSub], [newUser, newLang, newSub]]) => { + if (newSub && newLang && newUser && newUser.id !== 'admin') { + this.registerSubscription(newLang, newSub); + } else if (oldSub && !newSub) { + this.unregisterSubscription(oldSub); + } + }); + } + + public async enable(): Promise { + if (!this.notificationsSupported || !Notification || Notification.permission === 'denied') { + return false; + } + + let permission: NotificationPermission = Notification.permission; + if (permission === 'default') { + permission = await Notification.requestPermission(); + this.notificationsPermission.set(permission); + } + + if (permission === 'granted') { + const subscription = this._subscription(); + if (subscription) { + await this.registerSubscription(this._translateService.language(), subscription); + return true; + } + const { vapidPublicKey } = await firstValueFrom(this._wellKnownService.wellKnown$); + await this._swPush.requestSubscription({ serverPublicKey: vapidPublicKey }); + return true; + } else { + setHasRejectedPush(true); + return false; + } + } + + public async disable(keepSubscription: boolean = false): Promise { + if (!this.notificationsSupported) return; + + const subscription = this._subscription(); + if (!subscription) return; + + if (keepSubscription) { + await this.unregisterSubscription(subscription); + } else { + await this._swPush.unsubscribe(); + } + } + + private async registerSubscription(newLang: string, newSub: PushSubscription) { + await this._notificationsService.subscribeToNotifications({ + body: { + lang: newLang, + endpoint: newSub.endpoint, + p256DH: arrayBufferToBase64(newSub.getKey('p256dh')), + auth: arrayBufferToBase64(newSub.getKey('auth')), + }, + }); + } + + private async unregisterSubscription(oldSub: PushSubscription) { + await this._notificationsService.unsubscribeFromNotifications({ + body: { endpoint: oldSub.endpoint }, + }); + } +}