From 8a61d35e63661dfe3e68d542a7565f180e550a47 Mon Sep 17 00:00:00 2001 From: akastijn Date: Sat, 22 Aug 2026 19:27:02 +0200 Subject: [PATCH] Add `ServerMessageService` for broadcasting player state updates, implement `PlayerListState` model, and update `ChatService` to synchronize active player states across servers. --- .../altitudeweb/config/SecurityConfig.java | 3 + .../controllers/chat/ChatController.java | 26 ++++++ .../services/chat/ChatService.java | 34 +++++++ .../chat/event_publisher/EventPublisher.java | 27 ++++-- .../controllers/chat/ChatControllerTest.java | 41 +++++++++ .../services/chat/ChatServiceTest.java | 38 ++++++++ .../components/chat/chat-box.component.html | 11 ++- .../components/chat/chat-box.component.scss | 9 ++ .../components/chat/chat-box.component.ts | 18 ++-- .../altitude/chat/service/chat.service.ts | 88 +++++++++++++++++++ open_api/src/main/resources/chat-api.yml | 42 +++++++++ 11 files changed, 323 insertions(+), 14 deletions(-) diff --git a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java index bd79c45..c342c39 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java +++ b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java @@ -62,6 +62,9 @@ public class SecurityConfig { .requestMatchers("/api/mail/**").authenticated() .requestMatchers("/api/site/vote").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/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatController.java index ddf3b96..40764cc 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatController.java @@ -1,7 +1,9 @@ package com.alttd.altitudeweb.controllers.chat; 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.PlayerListVisibilityDto; import com.alttd.altitudeweb.model.ServerStateDto; import com.alttd.altitudeweb.services.chat.ChatService; import lombok.RequiredArgsConstructor; @@ -11,6 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.UUID; @Slf4j @RestController @@ -18,6 +21,29 @@ import java.util.List; public class ChatController implements ChatApi { private final ChatService chatService; + private final AuthenticatedUuid authenticatedUuid; + + @Override + public ResponseEntity hideUserFromPlayerList() { + UUID uuid = authenticatedUuid.getAuthenticatedUserUuid(); + chatService.hideUserFromPlayerList(uuid); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @Override + public ResponseEntity showUserInPlayerList() { + UUID uuid = authenticatedUuid.getAuthenticatedUserUuid(); + chatService.showUserInPlayerList(uuid); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @Override + public ResponseEntity getPlayerListVisibility() { + UUID uuid = authenticatedUuid.getAuthenticatedUserUuid(); + PlayerListVisibilityDto visibility = new PlayerListVisibilityDto() + .hidden(chatService.isUserHiddenFromPlayerList(uuid)); + return ResponseEntity.ok(visibility); + } @Override public ResponseEntity sendChatMessages(List chatMessageDtoList) { diff --git a/backend/src/main/java/com/alttd/altitudeweb/services/chat/ChatService.java b/backend/src/main/java/com/alttd/altitudeweb/services/chat/ChatService.java index e872c63..59afcc2 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/services/chat/ChatService.java +++ b/backend/src/main/java/com/alttd/altitudeweb/services/chat/ChatService.java @@ -23,6 +23,8 @@ import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; 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 java.time.Duration; @@ -41,6 +43,7 @@ public class ChatService { private final ServerMessageService serverMessageService; private final NavigableMap chatMessages = new TreeMap<>(); private final Map eventUserMap = new ConcurrentHashMap<>(); + private final Set hiddenUsers = ConcurrentHashMap.newKeySet(); private final Map serverStateCache = new HashMap<>(); @Value("${chat.allowed-servers}") @@ -100,13 +103,44 @@ public class ChatService { saveSession(chatSession); if (!eventPublisher.hasSubscribers(key) && eventUserMap.remove(key, eventUser)) { + hiddenUsers.remove(eventUser.uuid()); 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() { ArrayList activePlayers = eventUserMap.values().stream() .map(EventUser::uuid) + .filter(uuid -> !hiddenUsers.contains(uuid)) .collect(Collectors.toCollection(ArrayList::new)); serverMessageService.sendMessageToAll("player_state", PlayerListStateMapper.fromList(activePlayers)); } diff --git a/backend/src/main/java/com/alttd/altitudeweb/services/chat/event_publisher/EventPublisher.java b/backend/src/main/java/com/alttd/altitudeweb/services/chat/event_publisher/EventPublisher.java index b3ae288..52ec7fd 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/services/chat/event_publisher/EventPublisher.java +++ b/backend/src/main/java/com/alttd/altitudeweb/services/chat/event_publisher/EventPublisher.java @@ -9,11 +9,11 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; /** * Generic SSE event publisher, keyed by an arbitrary "unique" string @@ -28,6 +28,7 @@ import java.util.concurrent.CopyOnWriteArrayList; public class EventPublisher { private final Map> emitterMap = new ConcurrentHashMap<>(); + private final Map emitterCleanupMap = new ConcurrentHashMap<>(); /** * Invoked once a given emitter for uniqueKey is cleaned up (completed, @@ -49,7 +50,13 @@ public class EventPublisher { Instant sessionStart = Instant.now(); 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 emitter.onCompletion(cleanup); @@ -74,15 +81,13 @@ public class EventPublisher { @Scheduled(fixedRate = 20_000) public void heartbeat() { emitterMap.values().forEach(list -> { - List dead = new ArrayList<>(); for (SseEmitter emitter : list) { try { emitter.send(SseEmitter.event().comment("heartbeat")); } catch (IOException e) { - dead.add(emitter); + cleanup(emitter); } } - list.removeAll(dead); }); emitterMap.entrySet().removeIf(e -> e.getValue().isEmpty()); } @@ -93,15 +98,13 @@ public class EventPublisher { return; } - List dead = new ArrayList<>(); for (SseEmitter emitter : userEmitters) { try { emitter.send(SseEmitter.event().name(eventName).data(json)); } catch (IOException e) { - dead.add(emitter); + cleanup(emitter); } } - userEmitters.removeAll(dead); } public boolean hasSubscribers(String uniqueKey) { @@ -113,8 +116,16 @@ public class EventPublisher { 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, SessionCleanupCallback cleanupCallback) { + emitterCleanupMap.remove(emitter); List userEmitters = emitterMap.get(uniqueKey); if (userEmitters != null) { userEmitters.remove(emitter); diff --git a/backend/src/test/java/com/alttd/altitudeweb/controllers/chat/ChatControllerTest.java b/backend/src/test/java/com/alttd/altitudeweb/controllers/chat/ChatControllerTest.java index 8ff8986..28fc245 100644 --- a/backend/src/test/java/com/alttd/altitudeweb/controllers/chat/ChatControllerTest.java +++ b/backend/src/test/java/com/alttd/altitudeweb/controllers/chat/ChatControllerTest.java @@ -1,6 +1,8 @@ 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.PlayerListVisibilityDto; import com.alttd.altitudeweb.services.chat.ChatService; import org.junit.jupiter.api.Test; 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.ResponseEntity; +import java.util.UUID; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.verify; @@ -19,6 +23,9 @@ class ChatControllerTest { @Mock private ChatService chatService; + @Mock + private AuthenticatedUuid authenticatedUuid; + @InjectMocks private ChatController chatController; @@ -31,4 +38,38 @@ class ChatControllerTest { verify(chatService).updateServerState(serverStateDto); assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); } + + @Test + void hideUserFromPlayerListDelegatesForAuthenticatedUser() { + UUID uuid = UUID.randomUUID(); + org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid); + + ResponseEntity 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 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 response = chatController.getPlayerListVisibility(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(true, response.getBody().getHidden()); + } } diff --git a/backend/src/test/java/com/alttd/altitudeweb/services/chat/ChatServiceTest.java b/backend/src/test/java/com/alttd/altitudeweb/services/chat/ChatServiceTest.java index 7ed2715..d1ff5f2 100644 --- a/backend/src/test/java/com/alttd/altitudeweb/services/chat/ChatServiceTest.java +++ b/backend/src/test/java/com/alttd/altitudeweb/services/chat/ChatServiceTest.java @@ -14,6 +14,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; import java.time.Instant; 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.assertEquals; 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.eq; import static org.mockito.Mockito.*; @@ -198,4 +200,40 @@ class ChatServiceTest { verify(serverMessageService, times(1)).sendMessageToAll(eq("player_state"), stateCaptor.capture()); 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 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); + } } diff --git a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.html b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.html index e1044dd..9dfee9f 100644 --- a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.html +++ b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.html @@ -21,7 +21,16 @@ (keyup.enter)="sendMessage()" placeholder="Type a message..." maxlength="256" + [disabled]="hiddenMode()" /> - + + } diff --git a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.scss b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.scss index 2f96baa..d4f156c 100644 --- a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.scss +++ b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.scss @@ -89,5 +89,14 @@ &:active { background: #2a6ae0; } + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + &.visibility-toggle.active { + background: #b34747; + } } } diff --git a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.ts b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.ts index c6e5121..ae0a971 100644 --- a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.ts +++ b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.ts @@ -26,18 +26,22 @@ export class ChatBoxComponent { private readonly matSnackBar = inject(MatSnackBar); readonly messages = input.required(); + 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(); + } } diff --git a/frontend/src/app/pages/altitude/chat/service/chat.service.ts b/frontend/src/app/pages/altitude/chat/service/chat.service.ts index 666dc63..b48fdc7 100644 --- a/frontend/src/app/pages/altitude/chat/service/chat.service.ts +++ b/frontend/src/app/pages/altitude/chat/service/chat.service.ts @@ -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([]) private readonly _channels = signal([]) 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>(new Map()); public readonly userNameMap = signal>(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(); private readonly pendingUserNameRequests = new Set(); @@ -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(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 { diff --git a/open_api/src/main/resources/chat-api.yml b/open_api/src/main/resources/chat-api.yml index beefe33..f4c7794 100644 --- a/open_api/src/main/resources/chat-api.yml +++ b/open_api/src/main/resources/chat-api.yml @@ -12,6 +12,40 @@ tags: description: Data for displaying Chat messages to clients 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: post: tags: @@ -47,6 +81,14 @@ paths: description: Accepted components: schemas: + PlayerListVisibility: + type: object + required: + - hidden + properties: + hidden: + type: boolean + User: type: object required: