import {computed, inject, Injectable, OnDestroy, signal} from '@angular/core'; import {AuthService} from '@services/auth.service'; import {EventSourcePolyfill} from 'event-source-polyfill'; 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'; export interface ChatEvent { type: string; data: any; } interface SsePayloadEvent { data: string; } @Injectable({providedIn: 'root'}) export class ChatService implements OnDestroy { private eventSource?: EventSourcePolyfill; 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)); private readonly _servers = signal([]) public readonly servers = computed(() => this._servers().sort((a, b) => a.server.localeCompare(b.server))); private readonly _selectedServer = signal(''); public readonly selectedServer = this._selectedServer.asReadonly() connect() { if (this.eventSource) { return; // already connected } const jwt = this.authService.getJwt(); if (!jwt) { //TODO [Stijn] [2026-07-18]: Error when no JWT available (log in?) return; } const source = new EventSourcePolyfill('/api/chat/read/subscribe', { headers: { Authorization: `Bearer ${jwt}` }, heartbeatTimeout: 60000 }); this.eventSource = source; this.on(source, 'connected', (event) => { this.processChatMessages(event); }); this.on(source, 'chat', (event) => { const chatMessages = this.processChatMessages(event); chatMessages.forEach((message) => { const find = this.servers().find(server => message.server === server.server && message.server !== this._selectedServer()); if (find) { find.unreadMessages++; } }) }); source.onerror = (err) => { console.error('SSE error, polyfill will auto-reconnect:', err); }; } private processChatMessages(event: SsePayloadEvent): ChatMessage[] { const raw = JSON.parse(event.data) as RawChatMessage[]; const messages: ChatMessage[] = raw.map((m) => ({ ...m, messageJson: normalizeComponent(JSON.parse(m.messageJson)), })); this._messages.update((old) => [...old, ...messages]); //TODO [Stijn] [2026-07-19]: Handle servers by getting them from backend messages.map((message) => message.server).forEach((server) => { const found = this._servers().find(existing => existing.server === server); if (!found) { this._servers.update((old) => [...old, {server: server, unreadMessages: 0}]); } }) return messages; } public selectServer(server: string) { this._selectedServer.set(server); this._servers.update((old) => old.map((s) => ({...s, unreadMessages: 0}))); } // single, deliberate escape hatch from the broken DOM/polyfill type overlap — // everything else in this file stays fully typed private on(source: EventSourcePolyfill, eventName: string, handler: (event: SsePayloadEvent) => void): void { (source as unknown as { addEventListener: (type: string, listener: (event: SsePayloadEvent) => void) => void }) .addEventListener(eventName, handler); } disconnect(): void { this.eventSource?.close(); this.eventSource = undefined; } ngOnDestroy(): void { this.disconnect(); } }