Introduce Angular Chat Component with SSE integration for real-time updates.
This commit is contained in:
parent
04ca05cd14
commit
aa0c61cf5e
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
1
frontend/src/app/pages/altitude/chat/chat.component.html
Normal file
1
frontend/src/app/pages/altitude/chat/chat.component.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<p>chat works!</p>
|
||||||
32
frontend/src/app/pages/altitude/chat/chat.component.ts
Normal file
32
frontend/src/app/pages/altitude/chat/chat.component.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
74
frontend/src/app/pages/altitude/chat/service/chat.service.ts
Normal file
74
frontend/src/app/pages/altitude/chat/service/chat.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user