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",
|
||||
"@auth0/angular-jwt": "^5.2.0",
|
||||
"@types/three": "^0.177.0",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"ngx-cookie-service": "^20.0.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"three": "^0.177.0",
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
"@angular/build": "20.2.2",
|
||||
"@angular/cli": "20.2.2",
|
||||
"@angular/compiler-cli": "20.2.2",
|
||||
"@types/event-source-polyfill": "^1.0.5",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8080",
|
||||
"target": "http://10.0.0.121:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,10 @@ export const routes: Routes = [
|
|||
path: 'community',
|
||||
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',
|
||||
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]="['/birthdays']">Famous Birthdays</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
|
||||
Us</a></li>
|
||||
<li class="nav_li"><a class="nav_link2" [routerLink]="['/appeal']">Ban Appeal</a></li>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user