diff --git a/frontend/public/sounds/notification.mp3 b/frontend/public/sounds/notification.mp3 new file mode 100644 index 0000000..8bfa874 Binary files /dev/null and b/frontend/public/sounds/notification.mp3 differ diff --git a/frontend/src/app/pages/altitude/chat/chat.component.ts b/frontend/src/app/pages/altitude/chat/chat.component.ts index 5171aa9..15cf10b 100644 --- a/frontend/src/app/pages/altitude/chat/chat.component.ts +++ b/frontend/src/app/pages/altitude/chat/chat.component.ts @@ -42,7 +42,6 @@ export class ChatComponent implements OnInit, OnDestroy { ngOnInit(): void { this.chatService.connect(); - } ngOnDestroy(): void { diff --git a/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.html b/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.html index 03ec28a..060c7ec 100644 --- a/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.html +++ b/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.html @@ -1,6 +1,13 @@
- - {{ selectedServer() }} - + + {{ selectedServer() }} + +
+ @if (notificationEnabled()) { + + } @else { + + } +
diff --git a/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.scss b/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.scss index c98a9b9..ecaf1f3 100644 --- a/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.scss +++ b/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.scss @@ -14,3 +14,11 @@ font-weight: bold; padding-left: 10px; } + +.notification-icon { + cursor: pointer; + width: 20px; + height: 20px; + margin-left: auto; + margin-right: 10px; +} diff --git a/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.ts b/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.ts index c1f08db..86eca5e 100644 --- a/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.ts +++ b/frontend/src/app/pages/altitude/chat/components/chat-info/chat-info.component.ts @@ -1,14 +1,36 @@ -import {Component, input} from '@angular/core'; +import {Component, inject, input} from '@angular/core'; import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/server-color'; +import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service'; +import {NotificationEnabledSvgComponent} from '@pages/altitude/chat/svg/notification-enabled.svg'; +import {NotificationDisabledSvgComponent} from '@pages/altitude/chat/svg/notification-disabled.svg'; @Component({ selector: 'app-chat-info', - imports: [], + imports: [ + NotificationEnabledSvgComponent, + NotificationDisabledSvgComponent + ], templateUrl: './chat-info.component.html', styleUrl: './chat-info.component.scss' }) export class ChatInfoComponent { + private readonly notificationService: NotificationService = inject(NotificationService); + public readonly selectedServer = input.required(); + protected readonly notificationEnabled = this.notificationService.notificationEnabled; protected readonly getServerColor = getServerColor; protected readonly getServerTextColor = getServerTextColor; + + public enableNotifications() { + this.notificationService.requestPermission().then( + () => { + this.notificationService.enableNotifications(); + } + ); + } + + public disableNotifications() { + this.notificationService.disableNotifications(); + } + } diff --git a/frontend/src/app/pages/altitude/chat/service/chat-notification.service.ts b/frontend/src/app/pages/altitude/chat/service/chat-notification.service.ts new file mode 100644 index 0000000..0039ba3 --- /dev/null +++ b/frontend/src/app/pages/altitude/chat/service/chat-notification.service.ts @@ -0,0 +1,80 @@ +import {Injectable, signal} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class NotificationService { + + private readonly notificationSound = new Audio('/public/sounds/notification.mp3'); + private readonly notificationStorageKey = 'chat-notifications-enabled'; + private readonly _notificationEnabled = signal( + localStorage.getItem(this.notificationStorageKey) === 'true' + ); + public readonly notificationEnabled = this._notificationEnabled.asReadonly(); + + async requestPermission(): Promise { + if (!('Notification' in window)) { + console.log('This browser does not support notifications'); + return false; + } + + if (Notification.permission === 'granted') { + return true; + } + + if (Notification.permission === 'denied') { + console.log('Notification permission was denied'); + return false; + } + + const permission = await Notification.requestPermission(); + if (permission === 'granted') { + this.enableSound() + return true + } + return false; + } + + notify(title: string, body: string | null) { + if (!this.notificationEnabled()) { + return; + } + if (Notification.permission === 'granted') { + if (body === null) { + new Notification(title); + } else { + new Notification(title, {body}); + } + this.playSound(); + } + } + + private enableSound() { + this.notificationSound.muted = true; + this.notificationSound.play() + .then(() => { + this.notificationSound.pause(); + this.notificationSound.currentTime = 0; + this.notificationSound.muted = false; + }) + .catch((error) => { + console.log('Could not unlock notification sound:', error); + this.notificationSound.muted = false; + }); + } + + private playSound() { + this.notificationSound.currentTime = 0; + this.notificationSound.play().catch((error) => { + console.log('Could not play notification sound:', error); + }); + } + + enableNotifications() { + this._notificationEnabled.set(true); + localStorage.setItem(this.notificationStorageKey, 'true'); + } + + disableNotifications() { + this._notificationEnabled.set(false); + localStorage.setItem(this.notificationStorageKey, 'false'); + } +} diff --git a/frontend/src/app/pages/altitude/chat/service/chat.service.ts b/frontend/src/app/pages/altitude/chat/service/chat.service.ts index c14ae6f..3148d6b 100644 --- a/frontend/src/app/pages/altitude/chat/service/chat.service.ts +++ b/frontend/src/app/pages/altitude/chat/service/chat.service.ts @@ -5,6 +5,7 @@ import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object'; import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object'; import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util'; import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object'; +import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service'; export interface ChatEvent { type: string; @@ -18,6 +19,7 @@ interface SsePayloadEvent { @Injectable({providedIn: 'root'}) export class ChatService implements OnDestroy { private eventSource?: EventSourcePolyfill; + private readonly notificationService: NotificationService = inject(NotificationService); private readonly authService: AuthService = inject(AuthService) private readonly _messages = signal([]) public readonly messages = computed(() => this._messages().filter((message) => message.server === this._selectedServer()).sort((a, b) => b.timestamp - a.timestamp)); @@ -58,6 +60,7 @@ export class ChatService implements OnDestroy { find.unreadMessages++; } }) + this.notificationService.notify(`(${chatMessages.length}) new messages`, null); }); source.onerror = (err) => { diff --git a/frontend/src/app/pages/altitude/chat/svg/notification-disabled.svg.ts b/frontend/src/app/pages/altitude/chat/svg/notification-disabled.svg.ts new file mode 100644 index 0000000..984aaa0 --- /dev/null +++ b/frontend/src/app/pages/altitude/chat/svg/notification-disabled.svg.ts @@ -0,0 +1,16 @@ +import {Component} from '@angular/core'; + +@Component({ + selector: 'svg-notification-disabled', + imports: [], + template: ` + + + + ` +}) +export class NotificationDisabledSvgComponent { + +} diff --git a/frontend/src/app/pages/altitude/chat/svg/notification-enabled.svg.ts b/frontend/src/app/pages/altitude/chat/svg/notification-enabled.svg.ts new file mode 100644 index 0000000..e6a45f0 --- /dev/null +++ b/frontend/src/app/pages/altitude/chat/svg/notification-enabled.svg.ts @@ -0,0 +1,16 @@ +import {Component} from '@angular/core'; + +@Component({ + selector: 'svg-notification-enabled', + imports: [], + template: ` + + + + ` +}) +export class NotificationEnabledSvgComponent { + +}