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
@@ -62,6 +62,9 @@ public class SecurityConfig {
.requestMatchers("/api/mail/**").authenticated() .requestMatchers("/api/mail/**").authenticated()
.requestMatchers("/api/site/vote").authenticated() .requestMatchers("/api/site/vote").authenticated()
.requestMatchers("/api/appeal").authenticated() .requestMatchers("/api/appeal").authenticated()
.requestMatchers(HttpMethod.POST, "/api/chat/hide").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers(HttpMethod.POST, "/api/chat/show").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers(HttpMethod.GET, "/api/chat/visibility").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/chat/read/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue()) .requestMatchers("/api/chat/read/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
@@ -1,7 +1,9 @@
package com.alttd.altitudeweb.controllers.chat; package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.api.ChatApi; import com.alttd.altitudeweb.api.ChatApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.ChatMessageDto; import com.alttd.altitudeweb.model.ChatMessageDto;
import com.alttd.altitudeweb.model.PlayerListVisibilityDto;
import com.alttd.altitudeweb.model.ServerStateDto; import com.alttd.altitudeweb.model.ServerStateDto;
import com.alttd.altitudeweb.services.chat.ChatService; import com.alttd.altitudeweb.services.chat.ChatService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -11,6 +13,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.UUID;
@Slf4j @Slf4j
@RestController @RestController
@@ -18,6 +21,29 @@ import java.util.List;
public class ChatController implements ChatApi { public class ChatController implements ChatApi {
private final ChatService chatService; private final ChatService chatService;
private final AuthenticatedUuid authenticatedUuid;
@Override
public ResponseEntity<Void> hideUserFromPlayerList() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
chatService.hideUserFromPlayerList(uuid);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
@Override
public ResponseEntity<Void> showUserInPlayerList() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
chatService.showUserInPlayerList(uuid);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
@Override
public ResponseEntity<PlayerListVisibilityDto> getPlayerListVisibility() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
PlayerListVisibilityDto visibility = new PlayerListVisibilityDto()
.hidden(chatService.isUserHiddenFromPlayerList(uuid));
return ResponseEntity.ok(visibility);
}
@Override @Override
public ResponseEntity<Void> sendChatMessages(List<ChatMessageDto> chatMessageDtoList) { public ResponseEntity<Void> sendChatMessages(List<ChatMessageDto> chatMessageDtoList) {
@@ -23,6 +23,8 @@ import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.time.Duration; import java.time.Duration;
@@ -41,6 +43,7 @@ public class ChatService {
private final ServerMessageService serverMessageService; private final ServerMessageService serverMessageService;
private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>(); private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>();
private final Map<String, EventUser> eventUserMap = new ConcurrentHashMap<>(); private final Map<String, EventUser> eventUserMap = new ConcurrentHashMap<>();
private final Set<UUID> hiddenUsers = ConcurrentHashMap.newKeySet();
private final Map<String, ServerDto> serverStateCache = new HashMap<>(); private final Map<String, ServerDto> serverStateCache = new HashMap<>();
@Value("${chat.allowed-servers}") @Value("${chat.allowed-servers}")
@@ -100,13 +103,44 @@ public class ChatService {
saveSession(chatSession); saveSession(chatSession);
if (!eventPublisher.hasSubscribers(key) && eventUserMap.remove(key, eventUser)) { if (!eventPublisher.hasSubscribers(key) && eventUserMap.remove(key, eventUser)) {
hiddenUsers.remove(eventUser.uuid());
sendPlayerStateToServers(); sendPlayerStateToServers();
} }
} }
public synchronized void hideUserFromPlayerList(UUID uuid) {
requireActiveHeadMod(uuid);
if (hiddenUsers.add(uuid)) {
sendPlayerStateToServers();
}
}
public synchronized void showUserInPlayerList(UUID uuid) {
requireActiveHeadMod(uuid);
if (hiddenUsers.remove(uuid)) {
sendPlayerStateToServers();
}
}
public synchronized boolean isUserHiddenFromPlayerList(UUID uuid) {
requireActiveHeadMod(uuid);
return hiddenUsers.contains(uuid);
}
private void requireActiveHeadMod(UUID uuid) {
EventUser eventUser = eventUserMap.get(uuid.toString());
if (eventUser == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "No active chat session");
}
if (!eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only head moderators can change session visibility");
}
}
private void sendPlayerStateToServers() { private void sendPlayerStateToServers() {
ArrayList<UUID> activePlayers = eventUserMap.values().stream() ArrayList<UUID> activePlayers = eventUserMap.values().stream()
.map(EventUser::uuid) .map(EventUser::uuid)
.filter(uuid -> !hiddenUsers.contains(uuid))
.collect(Collectors.toCollection(ArrayList::new)); .collect(Collectors.toCollection(ArrayList::new));
serverMessageService.sendMessageToAll("player_state", PlayerListStateMapper.fromList(activePlayers)); serverMessageService.sendMessageToAll("player_state", PlayerListStateMapper.fromList(activePlayers));
} }
@@ -9,11 +9,11 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException; import java.io.IOException;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* Generic SSE event publisher, keyed by an arbitrary "unique" string * Generic SSE event publisher, keyed by an arbitrary "unique" string
@@ -28,6 +28,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
public class EventPublisher { public class EventPublisher {
private final Map<String, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>(); private final Map<String, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
private final Map<SseEmitter, Runnable> emitterCleanupMap = new ConcurrentHashMap<>();
/** /**
* Invoked once a given emitter for uniqueKey is cleaned up (completed, * Invoked once a given emitter for uniqueKey is cleaned up (completed,
@@ -49,7 +50,13 @@ public class EventPublisher {
Instant sessionStart = Instant.now(); Instant sessionStart = Instant.now();
log.info("Key {} subscribed, active emitters: {}", uniqueKey, countActive()); log.info("Key {} subscribed, active emitters: {}", uniqueKey, countActive());
Runnable cleanup = () -> handleCleanup(uniqueKey, emitter, sessionStart, cleanupCallback); AtomicBoolean cleanedUp = new AtomicBoolean();
Runnable cleanup = () -> {
if (cleanedUp.compareAndSet(false, true)) {
handleCleanup(uniqueKey, emitter, sessionStart, cleanupCallback);
}
};
emitterCleanupMap.put(emitter, cleanup);
// covers: clean disconnect (tab closed normally), server-side timeout, write failure // covers: clean disconnect (tab closed normally), server-side timeout, write failure
emitter.onCompletion(cleanup); emitter.onCompletion(cleanup);
@@ -74,15 +81,13 @@ public class EventPublisher {
@Scheduled(fixedRate = 20_000) @Scheduled(fixedRate = 20_000)
public void heartbeat() { public void heartbeat() {
emitterMap.values().forEach(list -> { emitterMap.values().forEach(list -> {
List<SseEmitter> dead = new ArrayList<>();
for (SseEmitter emitter : list) { for (SseEmitter emitter : list) {
try { try {
emitter.send(SseEmitter.event().comment("heartbeat")); emitter.send(SseEmitter.event().comment("heartbeat"));
} catch (IOException e) { } catch (IOException e) {
dead.add(emitter); cleanup(emitter);
} }
} }
list.removeAll(dead);
}); });
emitterMap.entrySet().removeIf(e -> e.getValue().isEmpty()); emitterMap.entrySet().removeIf(e -> e.getValue().isEmpty());
} }
@@ -93,15 +98,13 @@ public class EventPublisher {
return; return;
} }
List<SseEmitter> dead = new ArrayList<>();
for (SseEmitter emitter : userEmitters) { for (SseEmitter emitter : userEmitters) {
try { try {
emitter.send(SseEmitter.event().name(eventName).data(json)); emitter.send(SseEmitter.event().name(eventName).data(json));
} catch (IOException e) { } catch (IOException e) {
dead.add(emitter); cleanup(emitter);
} }
} }
userEmitters.removeAll(dead);
} }
public boolean hasSubscribers(String uniqueKey) { public boolean hasSubscribers(String uniqueKey) {
@@ -113,8 +116,16 @@ public class EventPublisher {
return emitterMap.values().stream().mapToLong(List::size).sum(); return emitterMap.values().stream().mapToLong(List::size).sum();
} }
private void cleanup(SseEmitter emitter) {
Runnable cleanup = emitterCleanupMap.get(emitter);
if (cleanup != null) {
cleanup.run();
}
}
private void handleCleanup(String uniqueKey, SseEmitter emitter, Instant sessionStart, private void handleCleanup(String uniqueKey, SseEmitter emitter, Instant sessionStart,
SessionCleanupCallback cleanupCallback) { SessionCleanupCallback cleanupCallback) {
emitterCleanupMap.remove(emitter);
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey); List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
if (userEmitters != null) { if (userEmitters != null) {
userEmitters.remove(emitter); userEmitters.remove(emitter);
@@ -1,6 +1,8 @@
package com.alttd.altitudeweb.controllers.chat; package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.ServerStateDto; import com.alttd.altitudeweb.model.ServerStateDto;
import com.alttd.altitudeweb.model.PlayerListVisibilityDto;
import com.alttd.altitudeweb.services.chat.ChatService; import com.alttd.altitudeweb.services.chat.ChatService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -10,6 +12,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -19,6 +23,9 @@ class ChatControllerTest {
@Mock @Mock
private ChatService chatService; private ChatService chatService;
@Mock
private AuthenticatedUuid authenticatedUuid;
@InjectMocks @InjectMocks
private ChatController chatController; private ChatController chatController;
@@ -31,4 +38,38 @@ class ChatControllerTest {
verify(chatService).updateServerState(serverStateDto); verify(chatService).updateServerState(serverStateDto);
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
} }
@Test
void hideUserFromPlayerListDelegatesForAuthenticatedUser() {
UUID uuid = UUID.randomUUID();
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
ResponseEntity<Void> response = chatController.hideUserFromPlayerList();
verify(chatService).hideUserFromPlayerList(uuid);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
}
@Test
void showUserInPlayerListDelegatesForAuthenticatedUser() {
UUID uuid = UUID.randomUUID();
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
ResponseEntity<Void> response = chatController.showUserInPlayerList();
verify(chatService).showUserInPlayerList(uuid);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
}
@Test
void getPlayerListVisibilityReturnsBackendState() {
UUID uuid = UUID.randomUUID();
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
org.mockito.Mockito.when(chatService.isUserHiddenFromPlayerList(uuid)).thenReturn(true);
ResponseEntity<PlayerListVisibilityDto> response = chatController.getPlayerListVisibility();
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(true, response.getBody().getHidden());
}
} }
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@@ -22,6 +23,7 @@ import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -198,4 +200,40 @@ class ChatServiceTest {
verify(serverMessageService, times(1)).sendMessageToAll(eq("player_state"), stateCaptor.capture()); verify(serverMessageService, times(1)).sendMessageToAll(eq("player_state"), stateCaptor.capture());
assertEquals(List.of(uuid), stateCaptor.getValue().getActivePlayers()); assertEquals(List.of(uuid), stateCaptor.getValue().getActivePlayers());
} }
@Test
void headModCanHideCurrentSessionFromPlayerState() {
UUID uuid = UUID.randomUUID();
EventUser eventUser = new EventUser(uuid, List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
chatService.subscribe(eventUser, "[]");
clearInvocations(serverMessageService);
chatService.hideUserFromPlayerList(uuid);
ArgumentCaptor<PlayerListState> stateCaptor = ArgumentCaptor.forClass(PlayerListState.class);
verify(serverMessageService).sendMessageToAll(eq("player_state"), stateCaptor.capture());
assertTrue(stateCaptor.getValue().getActivePlayers().isEmpty());
assertTrue(chatService.isUserHiddenFromPlayerList(uuid));
clearInvocations(serverMessageService);
chatService.showUserInPlayerList(uuid);
verify(serverMessageService).sendMessageToAll(eq("player_state"), stateCaptor.capture());
assertEquals(List.of(uuid), stateCaptor.getValue().getActivePlayers());
assertFalse(chatService.isUserHiddenFromPlayerList(uuid));
}
@Test
void modCannotHideCurrentSessionFromPlayerState() {
UUID uuid = UUID.randomUUID();
EventUser eventUser = new EventUser(uuid, List.of(PermissionClaimDto.MOD.getValue()), null);
chatService.subscribe(eventUser, "[]");
clearInvocations(serverMessageService);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> chatService.hideUserFromPlayerList(uuid));
assertEquals(403, exception.getStatusCode().value());
verifyNoInteractions(serverMessageService);
}
} }
@@ -21,7 +21,16 @@
(keyup.enter)="sendMessage()" (keyup.enter)="sendMessage()"
placeholder="Type a message..." placeholder="Type a message..."
maxlength="256" 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> </div>
} }
@@ -89,5 +89,14 @@
&:active { &:active {
background: #2a6ae0; 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); private readonly matSnackBar = inject(MatSnackBar);
readonly messages = input.required<ChatMessage[]>(); readonly messages = input.required<ChatMessage[]>();
readonly hiddenMode = this.chatService.hiddenMode;
readonly visibilityConnected = this.chatService.connected;
readonly visibilityUpdating = this.chatService.visibilityUpdating;
chatText = ''; chatText = '';
readonly canChat = computed(() => { readonly canChat = computed(() => {
const hasPrivilege = this.authService.hasAccess(['SCOPE_head_mod']); const hasPrivilege = this.authService.hasAccess(['SCOPE_head_mod']);
const isServerChannel = this.chatService.selectedChannel()?.type === 'SERVER'; const channelType = this.chatService.selectedChannel()?.type;
if (!hasPrivilege || !isServerChannel) { const isValidChannel = this.chatService.selectedChannel()?.type === 'SERVER' || this.chatService.selectedChannel()?.type === 'DM';
console.log(`User has privilege ${hasPrivilege}, user in server channel ${isServerChannel}`); return hasPrivilege && isValidChannel;
}
return hasPrivilege && isServerChannel;
}); });
sendMessage() { sendMessage() {
if (this.hiddenMode()) {
return;
}
const message = this.chatText.trim(); const message = this.chatText.trim();
if (!message) { if (!message) {
return; 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 {computed, inject, Injectable, OnDestroy, signal} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {AuthService} from '@services/auth.service'; import {AuthService} from '@services/auth.service';
import {EventSourcePolyfill} from 'event-source-polyfill'; import {EventSourcePolyfill} from 'event-source-polyfill';
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object'; 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 {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
import {ChatInfoService} from '@api'; import {ChatInfoService} from '@api';
import {ServerState} from '@pages/altitude/chat/objects/server-state.object'; import {ServerState} from '@pages/altitude/chat/objects/server-state.object';
import {CookieService} from 'ngx-cookie-service';
interface SsePayloadEvent { interface SsePayloadEvent {
data: string; data: string;
@@ -16,10 +18,15 @@ interface SsePayloadEvent {
@Injectable({providedIn: 'root'}) @Injectable({providedIn: 'root'})
export class ChatService implements OnDestroy { export class ChatService implements OnDestroy {
private readonly HIDDEN_MODE_COOKIE = 'chat-hidden-mode';
private eventSource?: EventSourcePolyfill; private eventSource?: EventSourcePolyfill;
private visibilityPollId?: number;
private visibilityRevision = 0;
private readonly notificationService: NotificationService = inject(NotificationService); private readonly notificationService: NotificationService = inject(NotificationService);
private readonly authService: AuthService = inject(AuthService) private readonly authService: AuthService = inject(AuthService)
private readonly chatInfoService: ChatInfoService = inject(ChatInfoService); private readonly chatInfoService: ChatInfoService = inject(ChatInfoService);
private readonly httpClient = inject(HttpClient);
private readonly cookieService = inject(CookieService);
private readonly _messages = signal<ChatMessage[]>([]) private readonly _messages = signal<ChatMessage[]>([])
private readonly _channels = signal<ChatChannel[]>([]) private readonly _channels = signal<ChatChannel[]>([])
public readonly channels = computed(() => this._channels().sort((a, b) => a.name.localeCompare(b.name))); 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 partieMap = signal<Map<string, string>>(new Map());
public readonly userNameMap = signal<Map<string, string>>(new Map()); public readonly userNameMap = signal<Map<string, string>>(new Map());
public readonly userListVisible = signal(true); 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 pendingPartyNameRequests = new Set<string>();
private readonly pendingUserNameRequests = new Set<string>(); private readonly pendingUserNameRequests = new Set<string>();
@@ -67,6 +80,9 @@ export class ChatService implements OnDestroy {
this.on(source, 'connected', (event) => { this.on(source, 'connected', (event) => {
this.processChatMessages(event); this.processChatMessages(event);
this._connected.set(true);
this.restoreVisibilityAfterConnection();
this.startVisibilityPolling();
}); });
this.on(source, 'chat', (event) => { this.on(source, 'chat', (event) => {
@@ -92,10 +108,77 @@ export class ChatService implements OnDestroy {
}); });
source.onerror = (err) => { source.onerror = (err) => {
this._connected.set(false);
console.error('SSE error, polyfill will auto-reconnect:', err); 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[] { private processChatMessages(event: SsePayloadEvent): ChatMessage[] {
const raw = JSON.parse(event.data) as RawChatMessage[]; const raw = JSON.parse(event.data) as RawChatMessage[];
const messages: ChatMessage[] = raw.map((m) => { const messages: ChatMessage[] = raw.map((m) => {
@@ -222,6 +305,11 @@ export class ChatService implements OnDestroy {
disconnect(): void { disconnect(): void {
this.eventSource?.close(); this.eventSource?.close();
this.eventSource = undefined; this.eventSource = undefined;
this._connected.set(false);
if (this.visibilityPollId !== undefined) {
window.clearInterval(this.visibilityPollId);
this.visibilityPollId = undefined;
}
} }
ngOnDestroy(): void { ngOnDestroy(): void {
+42
View File
@@ -12,6 +12,40 @@ tags:
description: Data for displaying Chat messages to clients description: Data for displaying Chat messages to clients
paths: paths:
/chat/hide:
post:
tags:
- chat
summary: Hide the authenticated user from the active player list for this session
operationId: hideUserFromPlayerList
responses:
"204":
description: User hidden for the current session
/chat/show:
post:
tags:
- chat
summary: Show the authenticated user in the active player list for this session
operationId: showUserInPlayerList
responses:
"204":
description: User shown for the current session
/chat/visibility:
get:
tags:
- chat
summary: Get the authenticated user's active player list visibility
operationId: getPlayerListVisibility
responses:
"200":
description: Current visibility mode
content:
application/json:
schema:
$ref: "#/components/schemas/PlayerListVisibility"
/chat/send/chat/message: /chat/send/chat/message:
post: post:
tags: tags:
@@ -47,6 +81,14 @@ paths:
description: Accepted description: Accepted
components: components:
schemas: schemas:
PlayerListVisibility:
type: object
required:
- hidden
properties:
hidden:
type: boolean
User: User:
type: object type: object
required: required: