AltitudeWeb/frontend/src/app/pages/altitude/chat/service/chat.service.ts

108 lines
3.7 KiB
TypeScript

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<ChatMessage[]>([])
public readonly messages = computed(() => this._messages().filter((message) => message.server === this._selectedServer()).sort((a, b) => b.timestamp - a.timestamp));
private readonly _servers = signal<ChatServer[]>([])
public readonly servers = computed(() => this._servers().sort((a, b) => a.server.localeCompare(b.server)));
private readonly _selectedServer = signal<string>('');
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();
}
}