Skip to content

Commit

Permalink
refactor: move web push related stuff into WebPushService
Browse files Browse the repository at this point in the history
Also added check for OculusBrowser and removed subscription from server after logout.

Refs: #85
  • Loading branch information
MaSch0212 committed Jun 23, 2024
1 parent 5448099 commit 32f25df
Show file tree
Hide file tree
Showing 5 changed files with 167 additions and 101 deletions.
56 changes: 8 additions & 48 deletions src/client/src/app/components/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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
Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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);
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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<void>();
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<LanguageOption[]>(() => [
{
Expand Down Expand Up @@ -176,33 +166,26 @@ 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(),
detail: this.translations.settings_notifications_errors_notGranted_description(),
sticky: true,
});
}
});
} else {
this._swPush.unsubscribe().finally(() => {
this.isUpdatingPushSubscription.set(false);
});
} else {
await this._webPushService.disable();
}
} finally {
this.isUpdatingPushSubscription.set(false);
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/client/src/app/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<AuthTokenInfo | null | undefined>(undefined);

Expand Down Expand Up @@ -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);

Expand Down
131 changes: 131 additions & 0 deletions src/client/src/app/services/web-push.service.ts
Original file line number Diff line number Diff line change
@@ -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<void>();
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<boolean> {
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<void> {
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 },
});
}
}

0 comments on commit 32f25df

Please sign in to comment.