Introduce Angular Chat Component with SSE integration for real-time updates.

This commit is contained in:
akastijn 2026-07-19 00:22:54 +02:00
parent 04ca05cd14
commit aa0c61cf5e
8 changed files with 119 additions and 1 deletions

View File

@ -24,6 +24,7 @@
"@angular/router": "20.2.2", "@angular/router": "20.2.2",
"@auth0/angular-jwt": "^5.2.0", "@auth0/angular-jwt": "^5.2.0",
"@types/three": "^0.177.0", "@types/three": "^0.177.0",
"event-source-polyfill": "^1.0.31",
"ngx-cookie-service": "^20.0.1", "ngx-cookie-service": "^20.0.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"three": "^0.177.0", "three": "^0.177.0",
@ -34,6 +35,7 @@
"@angular/build": "20.2.2", "@angular/build": "20.2.2",
"@angular/cli": "20.2.2", "@angular/cli": "20.2.2",
"@angular/compiler-cli": "20.2.2", "@angular/compiler-cli": "20.2.2",
"@types/event-source-polyfill": "^1.0.5",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0", "jasmine-core": "~5.6.0",
"karma": "~6.4.0", "karma": "~6.4.0",

View File

@ -1,6 +1,6 @@
{ {
"/api": { "/api": {
"target": "http://localhost:8080", "target": "http://10.0.0.121:8080",
"secure": false, "secure": false,
"changeOrigin": true "changeOrigin": true
} }

View File

@ -198,6 +198,10 @@ export const routes: Routes = [
path: 'community', path: 'community',
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent) loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
}, },
{
path: 'chat',
loadComponent: () => import('./pages/altitude/chat/chat.component').then(m => m.ChatComponent)
},
{ {
path: 'nicknames', path: 'nicknames',
loadComponent: () => import('./pages/reference/nicknames/nicknames.component').then(m => m.NicknamesComponent) loadComponent: () => import('./pages/reference/nicknames/nicknames.component').then(m => m.NicknamesComponent)

View File

@ -0,0 +1 @@
<p>chat works!</p>

View File

@ -0,0 +1,32 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {Subscription} from 'rxjs';
import {ChatService} from '@pages/altitude/chat/service/chat.service';
@Component({
selector: 'app-chat',
imports: [],
templateUrl: './chat.component.html',
styleUrl: './chat.component.scss'
})
export class ChatComponent implements OnInit, OnDestroy {
private sub?: Subscription;
private readonly liveEvents: ChatService = inject(ChatService)
ngOnInit(): void {
this.liveEvents.connect();
this.sub = this.liveEvents.onEvent().subscribe(event => {
//TODO enums for event types
if (event.type === 'chat') {
console.log(event.data)
} else if (event.type === 'connect') {
console.log(event.data)
}
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
this.liveEvents.disconnect(); // triggers onCompletion server-side, cleans up the emitter
}
}

View File

@ -0,0 +1,74 @@
import {inject, Injectable, OnDestroy} from '@angular/core';
import {Subject} from 'rxjs';
import {AuthService} from '@services/auth.service';
import {EventSourcePolyfill} from 'event-source-polyfill';
export interface ChatEvent {
type: string;
data: any;
}
interface SsePayloadEvent {
data: string;
}
@Injectable({providedIn: 'root'})
export class ChatService implements OnDestroy {
private eventSource?: EventSourcePolyfill;
private events$ = new Subject<ChatEvent>();
private readonly authService: AuthService = inject(AuthService)
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) => {
console.log('SSE connected:', event.data);
});
this.on(source, 'chat-message', (event) => {
this.events$.next({type: 'chat-message', data: JSON.parse(event.data)});
});
source.onerror = (err) => {
console.error('SSE error, polyfill will auto-reconnect:', err);
};
}
// 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);
}
onEvent() {
return this.events$.asObservable();
}
disconnect(): void {
this.eventSource?.close();
this.eventSource = undefined;
}
ngOnDestroy(): void {
this.disconnect();
}
}

View File

@ -130,6 +130,11 @@
<li class="nav_li"><a class="nav_link2" [routerLink]="['/team']">Team</a></li> <li class="nav_li"><a class="nav_link2" [routerLink]="['/team']">Team</a></li>
<li class="nav_li"><a class="nav_link2" [routerLink]="['/birthdays']">Famous Birthdays</a></li> <li class="nav_li"><a class="nav_link2" [routerLink]="['/birthdays']">Famous Birthdays</a></li>
<li class="nav_li"><a class="nav_link2" [routerLink]="['/community']">Community</a></li> <li class="nav_li"><a class="nav_link2" [routerLink]="['/community']">Community</a></li>
@if (isAuthenticated()) {
@if (hasAccess([PermissionClaim.HEAD_MOD])) {
<li class="nav_li"><a class="nav_link2" [routerLink]="['/chat']">Chat</a></li>
}
}
<li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" [routerLink]="['/contact']">Contact <li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" [routerLink]="['/contact']">Contact
Us</a></li> Us</a></li>
<li class="nav_li"><a class="nav_link2" [routerLink]="['/appeal']">Ban Appeal</a></li> <li class="nav_li"><a class="nav_link2" [routerLink]="['/appeal']">Ban Appeal</a></li>