Add ServerMessageService for broadcasting player state updates, implement PlayerListState model, and update ChatService to synchronize active player states across servers.

This commit is contained in:
2026-08-22 19:27:02 +02:00
parent 2a65f98493
commit 8a61d35e63
11 changed files with 323 additions and 14 deletions
@@ -21,7 +21,16 @@
(keyup.enter)="sendMessage()"
placeholder="Type a message..."
maxlength="256"
[disabled]="hiddenMode()"
/>
<button (click)="sendMessage()">Send</button>
<button (click)="sendMessage()" [disabled]="hiddenMode()">Send</button>
<button
class="visibility-toggle"
[class.active]="hiddenMode()"
[disabled]="!visibilityConnected() || visibilityUpdating()"
(click)="toggleHiddenMode()"
>
{{ hiddenMode() ? 'Show Online' : 'Hide Online' }}
</button>
</div>
}
@@ -89,5 +89,14 @@
&:active {
background: #2a6ae0;
}
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
&.visibility-toggle.active {
background: #b34747;
}
}
}
@@ -26,18 +26,22 @@ export class ChatBoxComponent {
private readonly matSnackBar = inject(MatSnackBar);
readonly messages = input.required<ChatMessage[]>();
readonly hiddenMode = this.chatService.hiddenMode;
readonly visibilityConnected = this.chatService.connected;
readonly visibilityUpdating = this.chatService.visibilityUpdating;
chatText = '';
readonly canChat = computed(() => {
const hasPrivilege = this.authService.hasAccess(['SCOPE_head_mod']);
const isServerChannel = this.chatService.selectedChannel()?.type === 'SERVER';
if (!hasPrivilege || !isServerChannel) {
console.log(`User has privilege ${hasPrivilege}, user in server channel ${isServerChannel}`);
}
return hasPrivilege && isServerChannel;
const channelType = this.chatService.selectedChannel()?.type;
const isValidChannel = this.chatService.selectedChannel()?.type === 'SERVER' || this.chatService.selectedChannel()?.type === 'DM';
return hasPrivilege && isValidChannel;
});
sendMessage() {
if (this.hiddenMode()) {
return;
}
const message = this.chatText.trim();
if (!message) {
return;
@@ -68,4 +72,8 @@ export class ChatBoxComponent {
});
}
}
toggleHiddenMode(): void {
this.chatService.toggleHiddenMode();
}
}
@@ -1,4 +1,5 @@
import {computed, inject, Injectable, OnDestroy, signal} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {AuthService} from '@services/auth.service';
import {EventSourcePolyfill} from 'event-source-polyfill';
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
@@ -9,6 +10,7 @@ import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
import {ChatInfoService} from '@api';
import {ServerState} from '@pages/altitude/chat/objects/server-state.object';
import {CookieService} from 'ngx-cookie-service';
interface SsePayloadEvent {
data: string;
@@ -16,10 +18,15 @@ interface SsePayloadEvent {
@Injectable({providedIn: 'root'})
export class ChatService implements OnDestroy {
private readonly HIDDEN_MODE_COOKIE = 'chat-hidden-mode';
private eventSource?: EventSourcePolyfill;
private visibilityPollId?: number;
private visibilityRevision = 0;
private readonly notificationService: NotificationService = inject(NotificationService);
private readonly authService: AuthService = inject(AuthService)
private readonly chatInfoService: ChatInfoService = inject(ChatInfoService);
private readonly httpClient = inject(HttpClient);
private readonly cookieService = inject(CookieService);
private readonly _messages = signal<ChatMessage[]>([])
private readonly _channels = signal<ChatChannel[]>([])
public readonly channels = computed(() => this._channels().sort((a, b) => a.name.localeCompare(b.name)));
@@ -30,6 +37,12 @@ export class ChatService implements OnDestroy {
public readonly partieMap = signal<Map<string, string>>(new Map());
public readonly userNameMap = signal<Map<string, string>>(new Map());
public readonly userListVisible = signal(true);
private readonly _connected = signal(false);
public readonly connected = this._connected.asReadonly();
private readonly _hiddenMode = signal(this.cookieService.get(this.HIDDEN_MODE_COOKIE) === 'true');
public readonly hiddenMode = this._hiddenMode.asReadonly();
private readonly _visibilityUpdating = signal(false);
public readonly visibilityUpdating = this._visibilityUpdating.asReadonly();
private readonly pendingPartyNameRequests = new Set<string>();
private readonly pendingUserNameRequests = new Set<string>();
@@ -67,6 +80,9 @@ export class ChatService implements OnDestroy {
this.on(source, 'connected', (event) => {
this.processChatMessages(event);
this._connected.set(true);
this.restoreVisibilityAfterConnection();
this.startVisibilityPolling();
});
this.on(source, 'chat', (event) => {
@@ -92,10 +108,77 @@ export class ChatService implements OnDestroy {
});
source.onerror = (err) => {
this._connected.set(false);
console.error('SSE error, polyfill will auto-reconnect:', err);
};
}
public toggleHiddenMode(): void {
if (!this._connected() || this._visibilityUpdating()) {
return;
}
this.setBackendHiddenMode(!this._hiddenMode());
}
private restoreVisibilityAfterConnection(): void {
if (this._hiddenMode()) {
this.setBackendHiddenMode(true);
} else {
this.validateVisibility();
}
}
private setBackendHiddenMode(hidden: boolean): void {
if (!this._connected()) {
return;
}
const revision = ++this.visibilityRevision;
this._visibilityUpdating.set(true);
const path = hidden ? '/api/chat/hide' : '/api/chat/show';
this.httpClient.post<void>(path, null).subscribe({
next: () => {
if (revision === this.visibilityRevision) {
this.applyHiddenMode(hidden);
}
this._visibilityUpdating.set(false);
},
error: (error) => {
console.error(`Failed to ${hidden ? 'hide' : 'show'} chat session:`, error);
this._visibilityUpdating.set(false);
this.validateVisibility();
}
});
}
private startVisibilityPolling(): void {
if (this.visibilityPollId !== undefined) {
return;
}
this.visibilityPollId = window.setInterval(() => this.validateVisibility(), 60_000);
}
private validateVisibility(): void {
if (!this._connected() || this._visibilityUpdating()) {
return;
}
const revision = this.visibilityRevision;
this.httpClient.get<{hidden: boolean}>('/api/chat/visibility').subscribe({
next: (visibility) => {
if (revision === this.visibilityRevision) {
this.applyHiddenMode(visibility.hidden);
}
},
error: (error) => console.error('Failed to validate chat visibility:', error)
});
}
private applyHiddenMode(hidden: boolean): void {
this._hiddenMode.set(hidden);
this.cookieService.set(this.HIDDEN_MODE_COOKIE, String(hidden), 365, '/');
}
private processChatMessages(event: SsePayloadEvent): ChatMessage[] {
const raw = JSON.parse(event.data) as RawChatMessage[];
const messages: ChatMessage[] = raw.map((m) => {
@@ -222,6 +305,11 @@ export class ChatService implements OnDestroy {
disconnect(): void {
this.eventSource?.close();
this.eventSource = undefined;
this._connected.set(false);
if (this.visibilityPollId !== undefined) {
window.clearInterval(this.visibilityPollId);
this.visibilityPollId = undefined;
}
}
ngOnDestroy(): void {