58 Commits
Author SHA1 Message Date
stijn 4623fbcc82 Update ALTITUDE_VERSION to 26.2 in constant.ts 2026-09-18 20:42:20 +02:00
stijn 610ef2f327 Add warning logs for missing emitters and failed event delivery in EventPublisher 2026-08-24 21:44:50 +02:00
stijn bd6174dd23 Add logging for server subscriptions in ServerMessageService 2026-08-22 22:40:18 +02:00
stijn 8a61d35e63 Add ServerMessageService for broadcasting player state updates, implement PlayerListState model, and update ChatService to synchronize active player states across servers. 2026-08-22 19:27:02 +02:00
stijn 2a65f98493 Add ServerMessageService for broadcasting player state updates, implement PlayerListState model, and update ChatService to synchronize active player states across servers. 2026-08-22 18:22:12 +02:00
stijn 423d9c9043 Add admin chat endpoint and request schemas for private messages, party messages, and punishments 2026-08-09 15:41:34 +02:00
stijn 7f94569575 Add admin chat endpoint and request schemas for private messages, party messages, and punishments 2026-08-09 14:53:25 +02:00
stijn 1a0bc902ac Refactor user name display logic: integrate MiniMessageComponent in UserListComponent and transition to ProcessedMiniMessageNode. Add server state caching in ChatService with eager state dispatch on subscription. 2026-08-09 02:04:11 +02:00
stijn 8c02543bca Add user list toggle with responsive visibility in ChatService and ChatInfoComponent, implement UserListComponent, and update styles for user list and toggles. 2026-08-09 00:59:44 +02:00
stijn 2705cf14ab Add ServerState support in ChatService with SSE listener and associated data model 2026-08-09 00:48:17 +02:00
stijn 65d0118e3d Implement server state handling in ChatController and ChatService, add ServerStateMapper utility, and create/update server state unit tests. 2026-08-09 00:41:36 +02:00
stijn b61b117f41 Enforce 256-character limit on chat messages with validation in ChatBoxComponent and schema updates. 2026-08-08 23:45:42 +02:00
stijn bdda71229d Update ChatService to initialize totalMessages to 1; adjust chat-info styles to use bold font for message count. 2026-08-08 23:41:27 +02:00
stijn 49b85bb695 Handle SERVER channel type in name-format.service to capitalize channel names. 2026-08-08 22:59:11 +02:00
stijn ce7c68511b Validate sender UUID in sendServerMessage to prevent spoofing and enhance security. 2026-08-02 18:43:12 +02:00
stijn 065db06e16 Add chat input functionality in ChatBoxComponent and backend support for sending server messages 2026-08-02 18:30:33 +02:00
stijn 8533cfa365 Remove unused test message logic and JSON serialization method from ServerMessageService. 2026-08-02 17:38:47 +02:00
stijn d3389159b6 Improve subscribe endpoint in CommandsToServerController: add error logging for missing or invalid tokens 2026-08-02 17:30:28 +02:00
stijn 8849612138 Update subscribe endpoint in CommandsToServerController to use X-Altitude-Token header instead of AuthenticationPrincipal; fix invalid token handling; add return in ServerMessageService test message logic. 2026-08-02 17:25:50 +02:00
stijn 9757ce2ffa Inject EventPublisher dependency into ChatServiceTest setup for enhanced test isolation and mocking. 2026-08-02 17:21:17 +02:00
stijn 4f67228c39 Update CommandsToServerController route and adjust security config to permit access to the new route 2026-08-02 17:16:59 +02:00
stijn b71a2f19b8 Refactor EventPublisher and ChatService: replace UUID-based keys with generic unique string keys, add session cleanup callbacks, and update related logic for better extensibility and reusability. 2026-08-02 17:06:53 +02:00
stijn f7edde65d6 Refactor ChatMessage model and components: rename messageJson to miniMessageNode for clarity and consistency. 2026-08-01 23:36:40 +02:00
stijn 90eb40caa7 Defer rendering of mini-message components for optimized loading using viewport and idle prefetch placeholders. 2026-08-01 23:36:04 +02:00
stijn c8b5a01c21 Refactor mini-message system: add precomputed styles for optimization, update models and components to use preprocessed nodes, and display total message counts in ChatInfo UI. 2026-08-01 23:33:34 +02:00
stijn 819739ff60 Add legacy color code support in NameFormatService and implement unit tests for colored name parsing. 2026-08-01 22:52:50 +02:00
stijn 5be3072b26 Extract display name logic to NameFormatService, refactor colored name parsing, and improve channel list scrolling. 2026-08-01 22:42:37 +02:00
stijn 5987ee4133 Remove redundant test assertion for sender visibility in ChatServiceTest 2026-08-01 22:24:27 +02:00
stijn 6a52d27c09 Improve gradient-colored name parsing in ChannelListComponent and add scroll support in the channel list UI. 2026-08-01 22:20:39 +02:00
stijn 73ea715b01 Add support for "SPY" channel type: updated models, ChatService, and ChannelListComponent to handle SPY channels and their name mappings. 2026-08-01 22:01:59 +02:00
stijn ed942d5ec5 Simplify MSG case handling in ChatService by directly comparing receiver UUID. 2026-08-01 21:52:29 +02:00
stijn a9d788b319 Group channels by type with toggleable collapsed view in ChannelListComponent: added grouping logic, collapsed state management, updated UI with group titles, and adjusted styles for indentation. 2026-08-01 21:46:02 +02:00
stijn d1acf39a22 Add gradient-colored display names in ChannelListComponent: implemented name parsing with gradient support, refactored UI logic to dynamically render colored name segments, and improved message handling in ChatService with null channel filtering. 2026-08-01 21:16:37 +02:00
stijn 8f9aea2e0f Refactor ChatService and ChannelListComponent: integrate ChatService into ChannelListComponent, rename partieId to partyId, update party and DM name mappings in UI, and remove debug log in AuthService. 2026-08-01 20:47:34 +02:00
stijn 8ecfb99b27 Add ChatInfoService for retrieving player and party names: integrated backend APIs, updated chat service with caching logic for names, and extended OpenAPI documentation. 2026-08-01 20:22:38 +02:00
stijn 79a62e9ce9 Add party name retrieval: implemented PartyMapper for database queries, updated ChatService with new getPartyName method, and extended /chat/party/{id}/name API endpoint. 2026-08-01 20:03:49 +02:00
stijn 8931bdc30d Add nickname retrieval support: implemented NicknameMapper for database queries, updated ChatService to return player names with optional nicknames, and extended /chat/player/{uuid}/name API endpoint. 2026-08-01 19:57:55 +02:00
stijn f9f05dcc88 Replace servers with channels in chat functionality: refactored UI components, service logic, and message model to support channels (SERVER, DM, PARTY, GAC). 2026-08-01 19:10:33 +02:00
stijn 0d6a4ee8dd Link party IDs to users in chat: added getPartyId query, updated EventUser with partyId field, and adjusted message filtering logic in ChatService. 2026-08-01 18:47:06 +02:00
stijn 3100ac21c3 Add ChatService test suite to verify message filtering and delivery to appropriate users. 2026-08-01 18:26:30 +02:00
stijn 3462d82716 Track chat session lifecycles: added session start/end times, database schema, and persistence logic. 2026-08-01 17:40:48 +02:00
stijn b1267068a5 Add type, channel, and receiver fields to chat model and filter messages by PUBLIC channel type in ChatService. 2026-07-19 22:54:35 +02:00
stijn 1c2478e8d0 Add type, channel, and receiver fields to chat model and database handling. 2026-07-19 22:52:14 +02:00
stijn 7750bf6439 Expand /api/chat/read/** access to include MOD permission. 2026-07-19 20:42:00 +02:00
stijn a390ef98e6 Expand moderator access to chat by including MOD permission in access checks. 2026-07-19 20:18:50 +02:00
stijn cd18fd4b96 Expand moderator roles by adding SCOPE_mod permission and updating authorization logic. 2026-07-19 20:17:01 +02:00
stijn 9e5148fa8c Ensure unique timestamps for chat messages to prevent collisions and fix date formatting in chat-box UI. 2026-07-19 19:20:38 +02:00
stijn 564582a6f9 Simplify notification logic by removing permission handling, consolidating methods, and refining server-based notification management. 2026-07-19 19:08:12 +02:00
stijn 660fd4eb9c Add mobile breakpoint detection to chat for responsive behavior and fullscreen prompt control 2026-07-19 18:57:19 +02:00
stijn 7138487912 Add fullscreen prompt for chat with enter/exit logic and styling adjustments 2026-07-19 18:55:53 +02:00
stijn ce6f59b543 Improve chat-box scrollbar styling with thin bars, transparency, and hover effects for better UI consistency. 2026-07-19 18:48:52 +02:00
stijn 915b64cdb0 Add per-server notification handling with server-specific enable/disable logic and message count in notifications. 2026-07-19 18:46:46 +02:00
stijn b415d2968d Add notification support with enable/disable functionality and sound effects for new messages. 2026-07-19 18:39:24 +02:00
stijn a3cb1247af Refine unread message count logic to reset only for the selected server. 2026-07-19 17:59:19 +02:00
stijn aca9bd45ab Reset unread message count for all servers when a new server is selected. 2026-07-19 17:58:31 +02:00
stijn 3f974b2dff Sort chat messages in descending order by timestamp for improved recency-based display. 2026-07-19 17:54:46 +02:00
stijn 51416aec01 Improve chat UI with consistent styling, hover effects, flexbox adjustments, and message container restructuring. Sort messages by timestamp for better readability. 2026-07-19 17:51:18 +02:00
stijn a5c6ab9020 Refine unread message count logic by excluding messages from the selected server. 2026-07-19 17:33:07 +02:00
85 changed files with 3105 additions and 280 deletions
+1
View File
@@ -93,3 +93,4 @@ liquibase*.properties
node node
.env .env
virtual_view/*
@@ -62,7 +62,10 @@ 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("/api/chat/read/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .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/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())
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
@@ -71,6 +74,10 @@ public class SecurityConfig {
.requestMatchers("/api/files/download/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/files/download/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/login/userLogin/**").permitAll() .requestMatchers("/api/login/userLogin/**").permitAll()
.requestMatchers("/api/site/chat/**").authenticated()
.requestMatchers("/api/site/chat/send-admin-chat/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
.requestMatchers("/api/site/chat/punish/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
.requestMatchers("/api/chat/server/send/**").permitAll()
.anyRequest().permitAll() .anyRequest().permitAll()
) )
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
@@ -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) {
@@ -28,7 +54,7 @@ public class ChatController implements ChatApi {
@Override @Override
public ResponseEntity<Void> updateServerStates(ServerStateDto serverStateDto) { public ResponseEntity<Void> updateServerStates(ServerStateDto serverStateDto) {
//TODO [Stijn] [2026-07-18]: Implement handling server state updates (push to listeners) chatService.updateServerState(serverStateDto);
throw new UnsupportedOperationException("Not implemented"); return new ResponseEntity<>(HttpStatus.ACCEPTED);
} }
} }
@@ -0,0 +1,46 @@
package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.api.ChatInfoApi;
import com.alttd.altitudeweb.model.PartyNameDto;
import com.alttd.altitudeweb.model.PlayerNameDto;
import com.alttd.altitudeweb.services.chat.ChatInfoService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@Slf4j
@RestController
@RequiredArgsConstructor
public class ChatInfoController implements ChatInfoApi {
private final ChatInfoService chatInfoService;
@Override
public ResponseEntity<PlayerNameDto> getName(UUID uuid) {
return chatInfoService.getPlayerName(uuid)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@Override
public ResponseEntity<PartyNameDto> getPartyName(String id) {
int partyId;
try {
partyId = Integer.parseInt(id);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().build();
}
return chatInfoService.getPartyName(partyId)
.map(partyName -> {
PartyNameDto partyNameDto = new PartyNameDto();
partyNameDto.setName(partyName);
return partyNameDto;
})
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
@@ -2,6 +2,7 @@ package com.alttd.altitudeweb.controllers.chat;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.Setter;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
@@ -11,10 +12,12 @@ import java.util.UUID;
public class ChatMessage { public class ChatMessage {
private final UUID uuid; private final UUID uuid;
private final Instant timestamp; @Setter
private Instant timestamp;
private final String server; private final String server;
//TODO [Stijn] [2026-07-18]: Handle channel types private final ChatMessageType type;
//private final String channel; private final String channel;
private final String receiver;
private final String messageJson; private final String messageJson;
private final boolean notBlocked; private final boolean notBlocked;
} }
@@ -4,37 +4,69 @@ import com.alttd.altitudeweb.database.chat.ChatLogDao;
import com.alttd.altitudeweb.model.ChatMessageDto; import com.alttd.altitudeweb.model.ChatMessageDto;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
import java.time.Instant;
import java.util.UUID; import java.util.UUID;
@UtilityClass @UtilityClass
public class ChatMessageMapper { public class ChatMessageMapper {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule()); .registerModule(new JavaTimeModule())
.enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS);
public ChatMessage fromDto(ChatMessageDto dto) { public static ChatMessage fromDto(ChatMessageDto dto) {
return ChatMessage.builder() return ChatMessage.builder()
.uuid(dto.getUuid()) .uuid(dto.getUuid())
.timestamp(dto.getTimestamp().toInstant())//TODO [Stijn] [2026-07-18]: Check this works, might be sending instant instead .timestamp(dto.getTimestamp().toInstant())
.server(dto.getServer()) .server(dto.getServer())
.type(fromDto(dto.getType()))
.channel(dto.getChannel())
.receiver(dto.getReceiver())
.messageJson(dto.getMessage()) .messageJson(dto.getMessage())
.notBlocked(!dto.getBlocked()) .notBlocked(!dto.getBlocked())
.build(); .build();
} }
public ChatMessage fromDao(ChatLogDao dao) { private static ChatMessageType fromDto(ChatMessageDto.TypeEnum type) {
return switch (type) {
case PUBLIC -> ChatMessageType.PUBLIC;
case GLOBAL -> ChatMessageType.GLOBAL;
case PARTY -> ChatMessageType.PARTY;
case GAC -> ChatMessageType.GAC;
case MSG -> ChatMessageType.MSG;
case CUSTOM -> ChatMessageType.CUSTOM;
case UNKNOWN_DEFAULT_OPEN_API -> throw new IllegalArgumentException("Unknown chat message type");
};
}
public static ChatMessage fromDao(ChatLogDao dao) {
return ChatMessage.builder() return ChatMessage.builder()
.uuid(UUID.fromString(dao.getUuid())) .uuid(UUID.fromString(dao.getUuid()))
.timestamp(dao.getTimeStamp().toInstant()) .timestamp(dao.getTimeStamp().toInstant())
.server(dao.getServer()) .server(dao.getServer())
.type(fromString(dao.getType()))
.channel(dao.getChannel())
.receiver(dao.getReceiver())
.messageJson(dao.getMiniMessage()) .messageJson(dao.getMiniMessage())
.notBlocked(!dao.isBlocked()) .notBlocked(!dao.isBlocked())
.build(); .build();
} }
private static ChatMessageType fromString(String type) {
return switch (type) {
case "public" -> ChatMessageType.PUBLIC;
case "global" -> ChatMessageType.GLOBAL;
case "party" -> ChatMessageType.PARTY;
case "gac" -> ChatMessageType.GAC;
case "msg" -> ChatMessageType.MSG;
case "custom" -> ChatMessageType.CUSTOM;
default -> throw new IllegalArgumentException("Unknown chat message type");
};
}
public static String toJson(ChatMessage chatMessage) { public static String toJson(ChatMessage chatMessage) {
try { try {
return OBJECT_MAPPER.writeValueAsString(chatMessage); return OBJECT_MAPPER.writeValueAsString(chatMessage);
@@ -0,0 +1,10 @@
package com.alttd.altitudeweb.controllers.chat;
public enum ChatMessageType {
PUBLIC,
GLOBAL,
PARTY,
GAC,
MSG,
CUSTOM;
}
@@ -0,0 +1,93 @@
package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.api.ServerMessageApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.PartyMessageRequestDto;
import com.alttd.altitudeweb.model.PrivateMessageRequestDto;
import com.alttd.altitudeweb.model.PunishRequestDto;
import com.alttd.altitudeweb.model.ServerMessageRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
import com.alttd.altitudeweb.services.chat.to_server.data.PartyChatFromWeb;
import com.alttd.altitudeweb.services.chat.to_server.data.PrivateChatFromWeb;
import com.alttd.altitudeweb.services.chat.to_server.data.PunishFromWeb;
import com.alttd.altitudeweb.services.chat.to_server.mappers.ChatFromWebMapper;
import com.alttd.altitudeweb.services.chat.to_server.mappers.PartyChatFromWebMapper;
import com.alttd.altitudeweb.services.chat.to_server.mappers.PrivateChatFromWebMapper;
import com.alttd.altitudeweb.services.chat.to_server.mappers.PunishFromWebMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
@Slf4j
@RestController
@RequiredArgsConstructor
public class ServerMessageController implements ServerMessageApi {
private final ServerMessageService serverMessageService;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final AuthenticatedUuid authenticatedUuid;
public static String toJson(Object object) {
try {
return OBJECT_MAPPER.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize object to JSON", e);
}
}
@Override
public ResponseEntity<Void> punishPlayer(String server, PunishRequestDto punishRequestDto) throws Exception {
validateUser(punishRequestDto.getExecutor());
return send(server, "web_punish", PunishFromWebMapper.fromDto(punishRequestDto));
}
@Override
public ResponseEntity<Void> sendAdminChat(String server, ServerMessageRequestDto serverMessageRequestDto) {
validateUser(serverMessageRequestDto.getUuid());
return send(server, "web_ac_chat", ChatFromWebMapper.fromDto(serverMessageRequestDto));
}
@Override
public ResponseEntity<Void> sendPartyMessage(PartyMessageRequestDto partyMessageRequestDto) {
validateUser(partyMessageRequestDto.getFrom());
return send("proxy", "web_party_chat", PartyChatFromWebMapper.fromDto(partyMessageRequestDto));
}
@Override
public ResponseEntity<Void> sendPrivateMessage(PrivateMessageRequestDto privateMessageRequestDto) {
validateUser(privateMessageRequestDto.getFrom());
return send("proxy", "web_private_chat", PrivateChatFromWebMapper.fromDto(privateMessageRequestDto));
}
@Override
public ResponseEntity<Void> sendServerMessage(String server, ServerMessageRequestDto serverMessageRequestDto) {
validateUser(serverMessageRequestDto.getUuid());
//TODO [Stijn] [2026-08-02]: Validate the user can send a message in this server (or do that in chat)
return send(server, "web_chat", ChatFromWebMapper.fromDto(serverMessageRequestDto));
}
private void validateUser(UUID uuid) {
UUID authenticatedUserUuid = authenticatedUuid.getAuthenticatedUserUuid();
if (!authenticatedUserUuid.equals(uuid)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot send message as another user");
}
}
private ResponseEntity<Void> send(String server, String channel, Object data) {
String json = toJson(data);
if (serverMessageService.sendMessage(server, channel, json)) {
return new ResponseEntity<>(HttpStatus.ACCEPTED);
} else {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
}
@@ -0,0 +1,24 @@
package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.model.ServerStateDto;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.experimental.UtilityClass;
@UtilityClass
public class ServerStateMapper {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule())
.enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS);
public static String toJson(ServerStateDto serverStateDto) {
try {
return OBJECT_MAPPER.writeValueAsString(serverStateDto);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize ServerStateDto to JSON", e);
}
}
}
@@ -0,0 +1,48 @@
package com.alttd.altitudeweb.controllers.event;
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.token.Token;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RequiredArgsConstructor
@Slf4j
@RestController
@RequestMapping("/api/chat/server/send")
public class CommandsToServerController {
private final ServerMessageService serverMessageService;
@Value("${altitudeweb.token}")
private String validToken;
@GetMapping(path = "/subscribe/{server}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@RequestHeader(value = "X-Altitude-Token", required = false) String token,
HttpServletResponse response, @PathVariable String server) {
if (validToken == null || validToken.equals("invalid-token")) {
log.error("Invalid token in config");
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
if (token == null) {
log.error("No token provided when trying to subscribe to {}", server);
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if (!token.equals(validToken)) {
log.error("Invalid token provided when trying to subscribe to {}", server);
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
response.setHeader("X-Accel-Buffering", "no"); // disables nginx buffering if present
response.setHeader("Cache-Control", "no-cache");
return serverMessageService.subscribe(server);
}
}
@@ -1,10 +1,15 @@
package com.alttd.altitudeweb.controllers.event; package com.alttd.altitudeweb.controllers.event;
import com.alttd.altitudeweb.controllers.chat.ChatMessageMapper; import com.alttd.altitudeweb.controllers.chat.ChatMessageMapper;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.chat.ChatLogMapper;
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSession;
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
import com.alttd.altitudeweb.model.PermissionClaimDto; import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.services.chat.ChatService; import com.alttd.altitudeweb.services.chat.ChatService;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher; import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser; import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
import com.alttd.altitudeweb.setup.Connection;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -21,7 +26,9 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -32,7 +39,6 @@ public class EventController {
private final static Duration MESSAGE_HISTORY_DURATION = Duration.ofHours(1); private final static Duration MESSAGE_HISTORY_DURATION = Duration.ofHours(1);
private final ChatService chatService; private final ChatService chatService;
private final EventPublisher eventPublisher;
@GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@AuthenticationPrincipal Jwt jwt, public SseEmitter subscribe(@AuthenticationPrincipal Jwt jwt,
@@ -42,7 +48,7 @@ public class EventController {
} }
List<String> authorities = jwt.getClaimAsStringList("authorities"); List<String> authorities = jwt.getClaimAsStringList("authorities");
if (authorities == null || !authorities.contains(PermissionClaimDto.HEAD_MOD.getValue())) { if (authorities == null || doesNotHavePermission(authorities)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN); throw new ResponseStatusException(HttpStatus.FORBIDDEN);
} }
@@ -62,6 +68,33 @@ public class EventController {
.map(ChatMessageMapper::toJson) .map(ChatMessageMapper::toJson)
.collect(Collectors.joining(",", "[", "]")); .collect(Collectors.joining(",", "[", "]"));
return eventPublisher.subscribe(new EventUser(subject, authorities), json); Integer partyId = getPartyId(subject);
EventUser eventUser = new EventUser(subject, authorities, partyId);
return chatService.subscribe(eventUser, json);
}
private Integer getPartyId(UUID subject) {
CompletableFuture<Integer> partyIdFuture = new CompletableFuture<>();
Connection.getConnection(Databases.CHAT)
.runQuery(sqlSession -> {
log.debug("Loading party id for uuid {}", subject);
try {
Integer partyId = sqlSession.getMapper(ChatLogMapper.class)
.getPartyId(subject.toString())
.orElse(null);
partyIdFuture.complete(partyId);
} catch (Exception e) {
log.error("Failed to load party id", e);
partyIdFuture.complete(null);
}
});
return partyIdFuture.join();
}
private static boolean doesNotHavePermission(List<String> authorities) {
return !authorities.contains(PermissionClaimDto.HEAD_MOD.getValue()) && !authorities.contains(PermissionClaimDto.MOD.getValue());
} }
} }
@@ -0,0 +1,44 @@
package com.alttd.altitudeweb.services.chat;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.chat.NicknameMapper;
import com.alttd.altitudeweb.database.chat.PartyMapper;
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
import com.alttd.altitudeweb.model.PlayerNameDto;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatInfoService {
public Optional<PlayerNameDto> getPlayerName(UUID uuid) {
String username = Connection.getConnection(Databases.LUCK_PERMS)
.runQueryWithResult(sqlSession -> sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString()));
if (username == null) {
return Optional.empty();
}
Optional<String> nickname = Connection.getConnection(Databases.CHAT)
.runQueryWithResult(sqlSession -> sqlSession.getMapper(NicknameMapper.class).getNicknameFromUUID(uuid.toString()));
PlayerNameDto playerNameDto = new PlayerNameDto();
playerNameDto.setName(username);
playerNameDto.setNickname(nickname.orElse(null));
return Optional.of(playerNameDto);
}
public Optional<String> getPartyName(int id) {
return Connection.getConnection(Databases.CHAT)
.runQueryWithResult(sqlSession -> sqlSession.getMapper(PartyMapper.class).getPartyName(id));
}
}
@@ -2,10 +2,19 @@ package com.alttd.altitudeweb.services.chat;
import com.alttd.altitudeweb.controllers.chat.ChatMessage; import com.alttd.altitudeweb.controllers.chat.ChatMessage;
import com.alttd.altitudeweb.controllers.chat.ChatMessageMapper; import com.alttd.altitudeweb.controllers.chat.ChatMessageMapper;
import com.alttd.altitudeweb.controllers.chat.ServerStateMapper;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.chat.ChatLogMapper; import com.alttd.altitudeweb.database.chat.ChatLogMapper;
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSession;
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
import com.alttd.altitudeweb.model.PermissionClaimDto; import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.model.ServerDto;
import com.alttd.altitudeweb.model.ServerStateDto;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher; import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
import com.alttd.altitudeweb.services.chat.to_server.mappers.PlayerListStateMapper;
import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -14,10 +23,14 @@ 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 java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Slf4j
@@ -26,10 +39,16 @@ import java.util.stream.Collectors;
public class ChatService { public class ChatService {
private static final Duration MAX_AGE = Duration.ofHours(1); private static final Duration MAX_AGE = Duration.ofHours(1);
private final EventPublisher eventPublisher;
private final ServerMessageService serverMessageService;
private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>();
private final Map<String, EventUser> eventUserMap = new ConcurrentHashMap<>();
private final Set<UUID> hiddenUsers = ConcurrentHashMap.newKeySet();
private final Map<String, ServerDto> serverStateCache = new HashMap<>();
@Value("${chat.allowed-servers}") @Value("${chat.allowed-servers}")
private String[] allowedServers; private String[] allowedServers;
private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>(); private long lastMillis = 0;
private final EventPublisher eventPublisher;
@Scheduled(cron = "0 * * * * *") @Scheduled(cron = "0 * * * * *")
public void clearOldMessages() { public void clearOldMessages() {
@@ -50,7 +69,8 @@ public class ChatService {
try { try {
sqlSession.getMapper(ChatLogMapper.class) sqlSession.getMapper(ChatLogMapper.class)
.getChatLogs(Instant.now().minus(Duration.ofHours(1)).toEpochMilli()) .getChatLogs(Instant.now().minus(Duration.ofHours(1)).toEpochMilli())
.stream().map(ChatMessageMapper::fromDao) .stream()
.map(ChatMessageMapper::fromDao)
.forEach(this::putChatMessage); .forEach(this::putChatMessage);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to load history count", e); log.error("Failed to load history count", e);
@@ -58,7 +78,99 @@ public class ChatService {
}); });
} }
public synchronized SseEmitter subscribe(EventUser eventUser, String json) {
String key = eventUser.uuid().toString();
boolean newUser = eventUserMap.put(key, eventUser) == null;
SseEmitter emitter = eventPublisher.subscribe(key, json, this::handleSessionEnd);
if (newUser) {
sendPlayerStateToServers();
}
sendServerStateToUser(key, eventUser);
return emitter;
}
private synchronized void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
EventUser eventUser = eventUserMap.get(key);
if (eventUser == null) {
log.error("Failed to find event user for key {}", key);
return;
}
ChatSession chatSession = ChatSession.builder()
.uuid(eventUser.uuid())
.session_start(sessionStart)
.session_end(sessionEnd)
.build();
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<UUID> activePlayers = eventUserMap.values().stream()
.map(EventUser::uuid)
.filter(uuid -> !hiddenUsers.contains(uuid))
.collect(Collectors.toCollection(ArrayList::new));
serverMessageService.sendMessageToAll("player_state", PlayerListStateMapper.fromList(activePlayers));
}
private void saveSession(ChatSession chatSession) {
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Saving chat session");
try {
sqlSession.getMapper(ChatSessionMapper.class).storeSession(chatSession);
log.debug("Saved chat session");
} catch (Exception e) {
log.error("Failed to save chat session", e);
}
});
}
private synchronized void setUniqueTimestamp(ChatMessage chatMessage) {
long timestamp = chatMessage.getTimestamp().toEpochMilli();
if (timestamp == lastMillis) {
timestamp += 1;
} else {
return;
}
lastMillis = timestamp;
chatMessage.setTimestamp(Instant.ofEpochMilli(timestamp));
}
private void putChatMessage(ChatMessage chatMessage) { private void putChatMessage(ChatMessage chatMessage) {
setUniqueTimestamp(chatMessage);
chatMessages.put(chatMessage.getTimestamp(), chatMessage); chatMessages.put(chatMessage.getTimestamp(), chatMessage);
} }
@@ -67,24 +179,83 @@ public class ChatService {
sendMessagesToListeners(chatMessageList); sendMessagesToListeners(chatMessageList);
} }
private void sendMessagesToListeners(List<ChatMessage> chatMessageList) { public synchronized void updateServerState(ServerStateDto serverStateDto) {
String jsonMessageListIncludingBlocked = chatMessageList.stream() serverStateDto.getServers().forEach(serverDto -> serverStateCache.put(serverDto.getName(), serverDto));
.map(ChatMessageMapper::toJson) sendToUsers("server-state", this::getServerStateJson);
.collect(Collectors.joining(",", "[", "]"));
String jsonMessageList = chatMessageList.stream()
.filter(ChatMessage::isNotBlocked)
.filter(chatMessage -> Arrays.asList(allowedServers).contains(chatMessage.getServer()))
.map(ChatMessageMapper::toJson)
.collect(Collectors.joining(",", "[", "]"));
eventPublisher.sendToUsers("chat", (eventUser) -> {
if (eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
return jsonMessageListIncludingBlocked;
} }
return jsonMessageList;
private String getServerStateJson(EventUser eventUser) {
if (eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
return ServerStateMapper.toJson(new ServerStateDto().servers(serverStateCache.values().stream().toList()));
}
List<ServerDto> filteredServers = serverStateCache.values().stream()
.filter(serverDto -> Arrays.asList(allowedServers).contains(serverDto.getName()))
.toList();
return ServerStateMapper.toJson(new ServerStateDto().servers(filteredServers));
}
private void sendServerStateToUser(String key, EventUser eventUser) {
String json = getServerStateJson(eventUser);
eventPublisher.sendToUser(key, "server-state", json);
}
private void sendMessagesToListeners(List<ChatMessage> chatMessageList) {
Map<ChatMessage, String> jsonCache = new HashMap<>();
for (ChatMessage message : chatMessageList) {
jsonCache.put(message, ChatMessageMapper.toJson(message));
}
sendToUsers("chat", (eventUser) ->
chatMessageList.stream()
.filter(chatMessage -> shouldReceive(eventUser, chatMessage))
.map(jsonCache::get)
.collect(Collectors.joining(",", "[", "]"))
);
}
/**
* Broadcasts to every currently subscribed key. The message content for
* each key is resolved via messageForUser, which now works off the
* unique key itself rather than an EventUser instance.
*/
public void sendToUsers(String eventName, MessageForUser messageForUser) {
eventUserMap.forEach((key, eventUser) -> {
String json = messageForUser.get(eventUser);
eventPublisher.sendToUser(key, eventName, json);
}); });
} }
private boolean shouldReceive(EventUser eventUser, ChatMessage chatMessage) {
if (eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
return true;
}
if (!Arrays.asList(allowedServers).contains(chatMessage.getServer())) {
return false;
}
if (!chatMessage.isNotBlocked()) {
return false;
}
return switch (chatMessage.getType()) {
case PUBLIC, GLOBAL -> true;
case PARTY -> {
Integer partyId = eventUser.partyId();
if (partyId == null) {
yield false;
}
yield chatMessage.getChannel().equals(String.valueOf(partyId));
}
case MSG -> eventUser.uuid().toString().equals(chatMessage.getReceiver());
case GAC -> eventUser.hasPermission(PermissionClaimDto.MOD);
case CUSTOM -> false;
};
}
public List<ChatMessage> getMessagesSince(Instant instant) { public List<ChatMessage> getMessagesSince(Instant instant) {
return chatMessages.tailMap(instant, false).values().stream().toList(); return chatMessages.tailMap(instant, false).values().stream().toList();
} }
@@ -7,39 +7,56 @@ import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.time.Duration;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
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
* The same key may be used by several concurrent connections
* (e.g., a user/server with multiple open tabs or connections).
* Each subscribe() call adds its own emitter under that key
* without evicting the others.
*/
@RequiredArgsConstructor @RequiredArgsConstructor
@Service @Service
@Slf4j @Slf4j
public class EventPublisher { public class EventPublisher {
private final Map<UUID, EventUser> eventUserMap = new ConcurrentHashMap<>(); private final Map<String, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
private final Map<UUID, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>(); private final Map<SseEmitter, Runnable> emitterCleanupMap = new ConcurrentHashMap<>();
public SseEmitter subscribe(EventUser eventUser, String initialData) { /**
* Invoked once a given emitter for uniqueKey is cleaned up (completed,
* timed out, or errored). Callers use this to do whatever bookkeeping
* they need (e.g., persisting a Session) using sessionStart/sessionEnd.
*/
@FunctionalInterface
public interface SessionCleanupCallback {
void onCleanup(String uniqueKey, Instant sessionStart, Instant sessionEnd);
}
public SseEmitter subscribe(String uniqueKey, SessionCleanupCallback cleanupCallback) {
return subscribe(uniqueKey, null, cleanupCallback);
}
public SseEmitter subscribe(String uniqueKey, String initialData, SessionCleanupCallback cleanupCallback) {
SseEmitter emitter = new SseEmitter(0L); // no built-in timeout; heartbeat governs liveness SseEmitter emitter = new SseEmitter(0L); // no built-in timeout; heartbeat governs liveness
emitterMap.computeIfAbsent(eventUser.uuid(), k -> new CopyOnWriteArrayList<>()).add(emitter); emitterMap.computeIfAbsent(uniqueKey, k -> new CopyOnWriteArrayList<>()).add(emitter);
eventUserMap.put(eventUser.uuid(), eventUser); Instant sessionStart = Instant.now();
log.info("User {} subscribed, active emitters: {}", eventUser.uuid(), countActive()); log.info("Key {} subscribed, active emitters: {}", uniqueKey, countActive());
AtomicBoolean cleanedUp = new AtomicBoolean();
Runnable cleanup = () -> { Runnable cleanup = () -> {
List<SseEmitter> userEmitters = emitterMap.get(eventUser.uuid()); if (cleanedUp.compareAndSet(false, true)) {
if (userEmitters != null) { handleCleanup(uniqueKey, emitter, sessionStart, cleanupCallback);
userEmitters.remove(emitter);
if (userEmitters.isEmpty()) {
emitterMap.remove(eventUser.uuid());
eventUserMap.remove(eventUser.uuid());
} }
}
log.info("Cleaned up emitter for {}, active emitters: {}", eventUser.uuid(), countActive());
}; };
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);
@@ -47,7 +64,11 @@ public class EventPublisher {
emitter.onError(e -> cleanup.run()); emitter.onError(e -> cleanup.run());
try { try {
if (initialData != null) {
emitter.send(SseEmitter.event().name("connected").data(initialData)); emitter.send(SseEmitter.event().name("connected").data(initialData));
} else {
emitter.send(SseEmitter.event().name("connected"));
}
} catch (IOException e) { } catch (IOException e) {
cleanup.run(); cleanup.run();
} }
@@ -60,44 +81,68 @@ 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());
} }
public void sendToUser(UUID uuid, String eventName, String json) { public void sendToUser(String uniqueKey, String eventName, String json) {
List<SseEmitter> userEmitters = emitterMap.get(uuid); List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
if (userEmitters == null) { if (userEmitters == null) {
log.warn("No emitters found for unique key: {}", uniqueKey);
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); log.warn("Failed to send event to user: {}", uniqueKey, e);
cleanup(emitter);
} }
} }
userEmitters.removeAll(dead); }
public boolean hasSubscribers(String uniqueKey) {
List<SseEmitter> emitters = emitterMap.get(uniqueKey);
return emitters != null && !emitters.isEmpty();
} }
private long countActive() { private long countActive() {
return emitterMap.values().stream().mapToLong(List::size).sum(); return emitterMap.values().stream().mapToLong(List::size).sum();
} }
public void sendToUsers(String eventName, MessageForUser messagesForUser) { private void cleanup(SseEmitter emitter) {
eventUserMap.values().forEach(emitter -> { Runnable cleanup = emitterCleanupMap.get(emitter);
String json = messagesForUser.get(emitter); if (cleanup != null) {
sendToUser(emitter.uuid(), eventName, json); cleanup.run();
}); }
}
private void handleCleanup(String uniqueKey, SseEmitter emitter, Instant sessionStart,
SessionCleanupCallback cleanupCallback) {
emitterCleanupMap.remove(emitter);
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
if (userEmitters != null) {
userEmitters.remove(emitter);
if (userEmitters.isEmpty()) {
emitterMap.remove(uniqueKey);
}
}
Instant sessionEnd = Instant.now();
Duration between = Duration.between(sessionStart, sessionEnd);
log.info("Cleaned up emitter for {}, active emitters: {}, session duration: {}",
uniqueKey, countActive(), between
);
if (cleanupCallback != null) {
cleanupCallback.onCleanup(uniqueKey, sessionStart, sessionEnd);
}
} }
} }
@@ -6,7 +6,7 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
public record EventUser(UUID uuid, List<String> authorities) { public record EventUser(UUID uuid, List<String> authorities, Integer partyId) {
public boolean hasPermission(PermissionClaimDto permission) { public boolean hasPermission(PermissionClaimDto permission) {
return authorities.contains(permission.getValue()); return authorities.contains(permission.getValue());
@@ -0,0 +1,65 @@
package com.alttd.altitudeweb.services.chat.to_server;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
@RequiredArgsConstructor
public class ServerMessageService {
private static final String PLAYER_STATE_CHANNEL = "player_state";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final Set<String> servers = ConcurrentHashMap.newKeySet();
private final EventPublisher eventPublisher;
private volatile String latestPlayerState;
public synchronized SseEmitter subscribe(String server) {
servers.add(server);
log.info("Server {} subscribed", server);
SseEmitter emitter = eventPublisher.subscribe(server, this::handleSessionEnd);
if (latestPlayerState != null) {
eventPublisher.sendToUser(server, PLAYER_STATE_CHANNEL, latestPlayerState);
}
return emitter;
}
private synchronized void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
log.info("Server session ended for {}. Active from {} for {}", key, sessionStart, Duration.between(sessionStart, sessionEnd));
}
public void sendMessageToAll(String channel, Object data) {
String json;
try {
json = OBJECT_MAPPER.writeValueAsString(data);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize server message", e);
}
if (PLAYER_STATE_CHANNEL.equals(channel)) {
latestPlayerState = json;
}
servers.forEach(server -> eventPublisher.sendToUser(server, channel, json));
}
public boolean sendMessage(String server, String channel, String json) {
if (!servers.contains(server)) {
log.warn("Server {} is not connected, not sending message", server);
return false;
}
eventPublisher.sendToUser(server, channel, json);
return true;
}
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import java.util.UUID;
@Builder
@Getter
public class ChatFromWeb {
private final UUID sender;
private final String message;
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Getter;
import java.util.UUID;
@Builder
@Getter
public class PartyChatFromWeb {
private final UUID sender;
private final String message;
private final String partyId;
}
@@ -0,0 +1,17 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.UUID;
@Builder
@Getter
public class PlayerListState {
private List<UUID> activePlayers;
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Getter;
import java.util.UUID;
@Builder
@Getter
public class PrivateChatFromWeb {
private final UUID sender;
private final String message;
private final UUID recipient;
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Getter;
import java.util.UUID;
@Builder
@Getter
public class PunishFromWeb {
private final UUID executor;
private final UUID target;
private final String type;
private final String reason;
private final String time;
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.model.ServerMessageRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
import lombok.experimental.UtilityClass;
@UtilityClass
public class ChatFromWebMapper {
public static ChatFromWeb fromDto(ServerMessageRequestDto dto) {
return ChatFromWeb.builder()
.sender(dto.getUuid())
.message(dto.getMessage())
.build();
}
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.model.PartyMessageRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.data.PartyChatFromWeb;
import lombok.experimental.UtilityClass;
@UtilityClass
public class PartyChatFromWebMapper {
public static PartyChatFromWeb fromDto(PartyMessageRequestDto dto) {
return PartyChatFromWeb.builder()
.sender(dto.getFrom())
.message(dto.getMessage())
.partyId(dto.getPartyId())
.build();
}
}
@@ -0,0 +1,19 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
import lombok.experimental.UtilityClass;
import java.util.ArrayList;
import java.util.UUID;
@UtilityClass
public class PlayerListStateMapper {
public static PlayerListState fromList(ArrayList<UUID> activeViewers) {
return PlayerListState.builder()
.activePlayers(activeViewers)
.build();
}
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.model.PrivateMessageRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.data.PrivateChatFromWeb;
import lombok.experimental.UtilityClass;
@UtilityClass
public class PrivateChatFromWebMapper {
public static PrivateChatFromWeb fromDto(PrivateMessageRequestDto dto) {
return PrivateChatFromWeb.builder()
.sender(dto.getFrom())
.message(dto.getMessage())
.recipient(dto.getTo())
.build();
}
}
@@ -0,0 +1,20 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.model.PunishRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.data.PunishFromWeb;
import lombok.experimental.UtilityClass;
@UtilityClass
public class PunishFromWebMapper {
public static PunishFromWeb fromDto(PunishRequestDto dto) {
return PunishFromWeb.builder()
.executor(dto.getExecutor())
.target(dto.getTarget())
.type(dto.getType().getValue())
.reason(dto.getReason())
.time(dto.getTime())
.build();
}
}
@@ -24,3 +24,4 @@ management.endpoints.web.exposure.include=mappings
server.forward-headers-strategy=native server.forward-headers-strategy=native
server.tomcat.remoteip.remote-ip-header=CF-Connecting-IP server.tomcat.remoteip.remote-ip-header=CF-Connecting-IP
server.tomcat.remoteip.trusted-proxies=10\\.0\\.0\\.103 server.tomcat.remoteip.trusted-proxies=10\\.0\\.0\\.103
altitudeweb.token=${TOKEN:invalid-token}
@@ -0,0 +1,75 @@
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;
import org.mockito.InjectMocks;
import org.mockito.Mock;
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;
@ExtendWith(MockitoExtension.class)
class ChatControllerTest {
@Mock
private ChatService chatService;
@Mock
private AuthenticatedUuid authenticatedUuid;
@InjectMocks
private ChatController chatController;
@Test
void testUpdateServerStatesDelegatesToService() {
ServerStateDto serverStateDto = new ServerStateDto();
ResponseEntity<Void> response = chatController.updateServerStates(serverStateDto);
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<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());
}
}
@@ -0,0 +1,239 @@
package com.alttd.altitudeweb.services.chat;
import com.alttd.altitudeweb.controllers.chat.ChatMessage;
import com.alttd.altitudeweb.controllers.chat.ChatMessageType;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.model.ServerDto;
import com.alttd.altitudeweb.model.ServerStateDto;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
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;
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.*;
class ChatServiceTest {
private ChatService chatService;
private EventPublisher eventPublisher;
private ServerMessageService serverMessageService;
@BeforeEach
void setUp() {
eventPublisher = mock(EventPublisher.class);
serverMessageService = mock(ServerMessageService.class);
chatService = spy(new ChatService(eventPublisher, serverMessageService));
ReflectionTestUtils.setField(chatService, "allowedServers", new String[]{"server1"});
}
@Test
void testPublicMessageSentToEveryone() {
ChatMessage message = ChatMessage.builder()
.uuid(UUID.randomUUID())
.type(ChatMessageType.PUBLIC)
.server("server1")
.messageJson("hello")
.notBlocked(true)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
String result = messageForUser.get(user);
assertTrue(result.contains("hello"));
}
@Test
void testGacMessageSentOnlyToStaff() {
ChatMessage message = ChatMessage.builder()
.uuid(UUID.randomUUID())
.type(ChatMessageType.GAC)
.server("server1")
.messageJson("staff chat")
.notBlocked(true)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
assertFalse(messageForUser.get(regularUser).contains("staff chat"), "Regular user should not see GAC message");
EventUser modUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.MOD.getValue()), null);
assertTrue(messageForUser.get(modUser).contains("staff chat"), "MOD should see GAC message");
}
@Test
void testMsgMessageSentToSenderAndReceiver() {
UUID senderUuid = UUID.randomUUID();
UUID receiverUuid = UUID.randomUUID();
ChatMessage message = ChatMessage.builder()
.uuid(senderUuid)
.type(ChatMessageType.MSG)
.server("server1")
.receiver(receiverUuid.toString())
.messageJson("private message")
.notBlocked(true)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser sender = new EventUser(senderUuid, List.of(), null);
EventUser receiver = new EventUser(receiverUuid, List.of(), null);
EventUser other = new EventUser(UUID.randomUUID(), List.of(), null);
assertTrue(messageForUser.get(receiver).contains("private message"), "Receiver should see the message");
assertFalse(messageForUser.get(other).contains("private message"), "Others should not see private message");
}
@Test
void testGlobalMessageSentToEveryone() {
ChatMessage message = ChatMessage.builder()
.uuid(UUID.randomUUID())
.type(ChatMessageType.GLOBAL)
.server("server1")
.messageJson("global hello")
.notBlocked(true)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
assertTrue(messageForUser.get(user).contains("global hello"));
}
@Test
void testBlockedMessageOnlySentToHeadMod() {
ChatMessage message = ChatMessage.builder()
.uuid(UUID.randomUUID())
.type(ChatMessageType.PUBLIC)
.server("server1")
.messageJson("blocked message")
.notBlocked(false)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
EventUser headModUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
assertFalse(messageForUser.get(regularUser).contains("blocked message"));
assertTrue(messageForUser.get(headModUser).contains("blocked message"));
}
@Test
void testUpdateServerStateFiltering() {
ServerStateDto serverStateDto = new ServerStateDto()
.addServersItem(new ServerDto().name("server1"))
.addServersItem(new ServerDto().name("server2"));
chatService.updateServerState(serverStateDto);
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(eq("server-state"), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
String regularResult = messageForUser.get(regularUser);
assertTrue(regularResult.contains("server1"), "Regular user should see allowed server");
assertFalse(regularResult.contains("server2"), "Regular user should NOT see non-allowed server");
EventUser headModUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
String headModResult = messageForUser.get(headModUser);
assertTrue(headModResult.contains("server1"), "HEAD_MOD should see allowed server");
assertTrue(headModResult.contains("server2"), "HEAD_MOD should see non-allowed server");
}
@Test
void subscribingUserBroadcastsActivePlayerStateOnce() {
UUID uuid = UUID.randomUUID();
EventUser eventUser = new EventUser(uuid, List.of(), null);
chatService.subscribe(eventUser, "[]");
chatService.subscribe(eventUser, "[]");
ArgumentCaptor<PlayerListState> stateCaptor = ArgumentCaptor.forClass(PlayerListState.class);
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<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);
}
}
@@ -0,0 +1,52 @@
package com.alttd.altitudeweb.services.chat.to_server;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ServerMessageServiceTest {
@Test
void broadcastsSerializedPlayerStateToEveryServer() {
EventPublisher eventPublisher = mock(EventPublisher.class);
when(eventPublisher.subscribe(any(), any(EventPublisher.SessionCleanupCallback.class)))
.thenReturn(new SseEmitter());
ServerMessageService service = new ServerMessageService(eventPublisher);
UUID uuid = UUID.randomUUID();
service.subscribe("server-one");
service.subscribe("server-two");
service.sendMessageToAll("player_state", PlayerListState.builder()
.activePlayers(List.of(uuid))
.build());
String expectedJson = "{\"activePlayers\":[\"" + uuid + "\"]}";
verify(eventPublisher).sendToUser("server-one", "player_state", expectedJson);
verify(eventPublisher).sendToUser("server-two", "player_state", expectedJson);
}
@Test
void newlySubscribedServerReceivesLatestPlayerState() {
EventPublisher eventPublisher = mock(EventPublisher.class);
when(eventPublisher.subscribe(eq("server-one"), any(EventPublisher.SessionCleanupCallback.class)))
.thenReturn(new SseEmitter());
ServerMessageService service = new ServerMessageService(eventPublisher);
service.sendMessageToAll("player_state", PlayerListState.builder()
.activePlayers(List.of())
.build());
service.subscribe("server-one");
verify(eventPublisher).sendToUser("server-one", "player_state", "{\"activePlayers\":[]}");
}
}
@@ -12,6 +12,9 @@ public class ChatLogDao {
private final String uuid; private final String uuid;
private final Timestamp timeStamp; private final Timestamp timeStamp;
private final String server; private final String server;
private final String type;
private final String channel;
private final String receiver;
private final String miniMessage; private final String miniMessage;
private final boolean blocked; private final boolean blocked;
@@ -4,15 +4,22 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface ChatLogMapper { public interface ChatLogMapper {
@Select(""" @Select("""
SELECT uuid, time_stamp, server, mini_message, blocked SELECT uuid, time_stamp, server, type, channel, receiver, mini_message, blocked
FROM chat_log FROM chat_log
WHERE mini_message IS NOT NULL WHERE mini_message IS NOT NULL
AND time_stamp >= #{since} AND time_stamp >= #{since}
""") """)
List<ChatLogDao> getChatLogs(@Param("since") long since); List<ChatLogDao> getChatLogs(@Param("since") long since);
@Select("""
SELECT party_id FROM chat_users
WHERE uuid = #{uuid}
""")
Optional<Integer> getPartyId(@Param("uuid") String uuid);
} }
@@ -0,0 +1,15 @@
package com.alttd.altitudeweb.database.chat;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
public interface NicknameMapper {
@Select("""
SELECT nickname
FROM nicknames
WHERE uuid = #{uuid}
""")
Optional<String> getNicknameFromUUID(@Param("uuid") String uuid);
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.database.chat;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
public interface PartyMapper {
@Select("""
SELECT party_name FROM parties
WHERE id = #{id}
""")
Optional<String> getPartyName(@Param("id") int id);
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.database.web_db.chat_session;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.time.Instant;
import java.util.UUID;
@Getter
@Builder
public class ChatSession {
private final UUID uuid;
private final Instant session_start;
private final Instant session_end;
}
@@ -0,0 +1,13 @@
package com.alttd.altitudeweb.database.web_db.chat_session;
import org.apache.ibatis.annotations.Insert;
public interface ChatSessionMapper {
@Insert("""
INSERT INTO chat_session (uuid, session_start, session_end)
VALUES (#{uuid}, #{session_start}, #{session_end})
""")
void storeSession(ChatSession chatSession);
}
@@ -144,6 +144,26 @@ public class Connection {
}).start(); }).start();
} }
public <T> T runQueryWithResult(QueryFunction<T> function) {
if (sqlSessionFactory == null) {
sqlSessionFactory = createSqlSessionFactory(settings, addMappers);
}
try (SqlSession session = sqlSessionFactory.openSession()) {
T result = function.apply(session);
session.commit();
return result;
} catch (Exception e) {
log.error("Failed to run query", e);
throw e;
}
}
@FunctionalInterface
public interface QueryFunction<T> {
T apply(SqlSession session);
}
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) { private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
try { try {
Configuration configuration = getConfiguration(settings); Configuration configuration = getConfiguration(settings);
@@ -2,7 +2,8 @@ package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.chat.ChatLogMapper; import com.alttd.altitudeweb.database.chat.ChatLogMapper;
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper; import com.alttd.altitudeweb.database.chat.NicknameMapper;
import com.alttd.altitudeweb.database.chat.PartyMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@@ -12,6 +13,8 @@ public class InitializeChat {
log.info("Initializing Chat"); log.info("Initializing Chat");
Connection.getConnection(Databases.CHAT, (configuration) -> { Connection.getConnection(Databases.CHAT, (configuration) -> {
configuration.addMapper(ChatLogMapper.class); configuration.addMapper(ChatLogMapper.class);
configuration.addMapper(NicknameMapper.class);
configuration.addMapper(PartyMapper.class);
}).join(); }).join();
log.debug("Initialized Chat"); log.debug("Initialized Chat");
} }
@@ -4,6 +4,7 @@ import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.KeyPairMapper; import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper; import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.database.web_db.SettingsMapper; import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper; import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper; import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper; import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
@@ -28,6 +29,7 @@ public class InitializeWebDb {
configuration.addMapper(DiscordAppealMapper.class); configuration.addMapper(DiscordAppealMapper.class);
configuration.addMapper(StaffApplicationMapper.class); configuration.addMapper(StaffApplicationMapper.class);
configuration.addMapper(EmailVerificationMapper.class); configuration.addMapper(EmailVerificationMapper.class);
configuration.addMapper(ChatSessionMapper.class);
}).join() }).join()
.runQuery(sqlSession -> { .runQuery(sqlSession -> {
createSettingsTable(sqlSession); createSettingsTable(sqlSession);
@@ -38,6 +40,7 @@ public class InitializeWebDb {
createdDiscordAppealTable(sqlSession); createdDiscordAppealTable(sqlSession);
createStaffApplicationsTable(sqlSession); createStaffApplicationsTable(sqlSession);
createUserEmailsTable(sqlSession); createUserEmailsTable(sqlSession);
createChatSessionTable(sqlSession);
}); });
log.debug("Initialized WebDb"); log.debug("Initialized WebDb");
} }
@@ -208,4 +211,21 @@ public class InitializeWebDb {
} }
} }
private static void createChatSessionTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS chat_session (
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
uuid UUID NOT NULL,
session_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
session_end TIMESTAMP NULL,
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
} }
Binary file not shown.
@@ -6,24 +6,47 @@
</div> </div>
</app-header> </app-header>
@if (showFullscreenPrompt()) {
<div class="fullscreen-prompt-backdrop">
<div class="fullscreen-prompt">
<h2>Enter fullscreen?</h2>
<p>Would you like to view chat in fullscreen mode?</p>
<div class="fullscreen-prompt-actions">
<button type="button" class="secondary" (click)="skipFullscreen()">No thanks</button>
<button type="button" (click)="enterFullscreen()">Go fullscreen</button>
</div>
</div>
</div>
}
<main> <main>
<app-full-size [hideFooter]="true"> <app-full-size [hideFooter]="true">
<section class="darkmodeSection full-height"> <section class="darkmodeSection full-height">
<div class="page-layout"> <div class="page-layout">
<div class="server-list"> @if (!isMobile() || !userListVisible()) {
<app-server-list [servers]="servers()" [selectedServer]="selectedServer()" <div class="channel-list">
(selectedServerChange)="onSelectedServerChange($event)"> <app-channel-list [channels]="channels()" [selectedChannel]="selectedChannel()"
(selectedChannelChange)="onSelectedChannelChange($event)">
</app-server-list> </app-channel-list>
</div> </div>
}
<div class="chat"> <div class="chat">
<div class="chat-info"> <div class="chat-info">
<app-chat-info [selectedServer]="selectedServer()"></app-chat-info> <div class="chat-info-container">
<app-chat-info [selectedChannel]="selectedChannel()" [isMobile]="isMobile()"></app-chat-info>
</div>
</div> </div>
<div class="chat-box"> <div class="chat-box">
<app-chat-box [messages]="messages()"></app-chat-box> <app-chat-box [messages]="messages()"></app-chat-box>
</div> </div>
</div> </div>
@if (userListVisible()) {
<div class="user-list">
<app-user-list></app-user-list>
</div>
}
</div> </div>
</section> </section>
</app-full-size> </app-full-size>
@@ -1,10 +1,66 @@
.fullscreen-prompt-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgb(0 0 0 / 70%);
}
.fullscreen-prompt {
width: min(420px, 100%);
padding: 24px;
border-radius: 14px;
background: #1f1f1f;
color: #ffffff;
box-shadow: 0 20px 60px rgb(0 0 0 / 45%);
text-align: center;
h2 {
margin: 0 0 10px;
}
p {
margin: 0 0 22px;
color: rgb(255 255 255 / 75%);
}
}
.fullscreen-prompt-actions {
display: flex;
justify-content: center;
gap: 12px;
button {
cursor: pointer;
border: 0;
border-radius: 8px;
padding: 10px 16px;
background: #4f8cff;
color: #ffffff;
font-weight: 700;
&.secondary {
background: rgb(255 255 255 / 14%);
}
}
}
.page-layout { .page-layout {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
height: 100%; height: 100%;
.server-list { .channel-list {
flex: 0 0 auto;
width: max-content;
min-width: 100px;
}
.user-list {
flex: 0 0 auto; flex: 0 0 auto;
width: max-content; width: max-content;
min-width: 100px; min-width: 100px;
@@ -20,6 +76,16 @@
.chat-info { .chat-info {
flex: 0 0 auto; flex: 0 0 auto;
padding: 5px 5px 2px 5px; padding: 5px 5px 2px 5px;
.chat-info-container {
display: flex;
align-items: center;
gap: 10px;
app-chat-info {
flex: 1 1 auto;
}
}
} }
.chat-box { .chat-box {
@@ -27,6 +93,7 @@
min-width: 0; min-width: 0;
padding: 0 5px 0 5px; padding: 0 5px 0 5px;
min-height: 0; min-height: 0;
box-sizing: border-box;
} }
} }
} }
@@ -1,12 +1,15 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core'; import {AfterViewInit, Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
import {Subscription} from 'rxjs'; import {Subscription} from 'rxjs';
import {ChatService} from '@pages/altitude/chat/service/chat.service'; import {ChatService} from '@pages/altitude/chat/service/chat.service';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {HeaderComponent} from '@header/header.component'; import {HeaderComponent} from '@header/header.component';
import {MiniMessageInteractionService} from '@pages/altitude/chat/mini-message/mini-message-interaction.service'; import {MiniMessageInteractionService} from '@pages/altitude/chat/mini-message/mini-message-interaction.service';
import {FullSizeComponent} from '@shared-components/full-size/full-size.component'; import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
import {ChatBoxComponent} from '@pages/altitude/chat/components/chat/chat-box.component'; import {ChatBoxComponent} from '@pages/altitude/chat/components/chat/chat-box.component';
import {ServerListComponent} from '@pages/altitude/chat/components/server-list/server-list.component'; import {ChannelListComponent} from '@pages/altitude/chat/components/channel-list/channel-list.component';
import {ChatInfoComponent} from '@pages/altitude/chat/components/chat-info/chat-info.component'; import {ChatInfoComponent} from '@pages/altitude/chat/components/chat-info/chat-info.component';
import {UserListComponent} from '@pages/altitude/chat/components/user-list/user-list.component';
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
@Component({ @Component({
selector: 'app-chat', selector: 'app-chat',
@@ -14,39 +17,85 @@ import {ChatInfoComponent} from '@pages/altitude/chat/components/chat-info/chat-
HeaderComponent, HeaderComponent,
FullSizeComponent, FullSizeComponent,
ChatBoxComponent, ChatBoxComponent,
ServerListComponent, ChannelListComponent,
ChatInfoComponent ChatInfoComponent,
UserListComponent
], ],
templateUrl: './chat.component.html', templateUrl: './chat.component.html',
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
providers: [MiniMessageInteractionService] providers: [MiniMessageInteractionService]
}) })
export class ChatComponent implements OnInit, OnDestroy { export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
private sub?: Subscription; private sub?: Subscription;
private readonly chatService: ChatService = inject(ChatService) private readonly chatService: ChatService = inject(ChatService)
protected readonly messages = this.chatService.messages; protected readonly messages = this.chatService.messages;
protected readonly servers = this.chatService.servers; protected readonly channels = this.chatService.channels;
protected readonly selectedServer = this.chatService.selectedServer; protected readonly selectedChannel = this.chatService.selectedChannel;
protected readonly showFullscreenPrompt = signal(false);
protected readonly isMobile = signal(false);
protected readonly userListVisible = this.chatService.userListVisible;
constructor(private interaction: MiniMessageInteractionService) { constructor(private interaction: MiniMessageInteractionService, private breakpointObserver: BreakpointObserver) {
interaction.clicks$.subscribe(({action, value}) => { interaction.clicks$.subscribe(({action, value}) => {
if (action === 'run_command') { if (action === 'run_command') {
alert(value); alert(value);
} }
}); });
this.breakpointObserver
.observe([Breakpoints.Handset])
.subscribe(result => {
const isMobile = result.matches;
this.isMobile.set(isMobile);
this.showFullscreenPrompt.set(isMobile);
if (isMobile) {
this.userListVisible.set(false);
} else {
this.userListVisible.set(true);
}
});
} }
public onSelectedServerChange(server: string) { public onSelectedChannelChange(channel: ChatChannel) {
this.chatService.selectServer(server); this.chatService.selectChannel(channel);
} }
protected toggleUserList(): void {
this.chatService.toggleUserList();
}
protected enterFullscreen(): void {
this.showFullscreenPrompt.set(false);
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {
// Ignore fullscreen request failures.
});
}
}
protected skipFullscreen(): void {
this.showFullscreenPrompt.set(false);
}
ngOnInit(): void { ngOnInit(): void {
this.chatService.connect(); this.chatService.connect();
}
ngAfterViewInit(): void {
document.documentElement.requestFullscreen().catch(() => {
console.error('Failed to enter fullscreen');
});
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.sub?.unsubscribe(); this.sub?.unsubscribe();
this.chatService.disconnect(); // triggers onCompletion server-side, cleans up the emitter this.chatService.disconnect();
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {
console.error('Failed to exit fullscreen');
});
}
} }
} }
@@ -0,0 +1,28 @@
<div class="channel-list">
<p>Channels</p>
<div class="scroll">
@for (group of groupedChannels(); track group.type) {
<p class="channel-group-title" (click)="toggleGroup(group.type)">
{{ group.type }} {{ collapsedGroups().has(group.type) ? '▼' : '▲' }}
</p>
@if (!collapsedGroups().has(group.type)) {
@for (channel of group.channels; track channel.name) {
<div class="channel indented"
[class.selected]="selectedChannel()?.name === channel.name && selectedChannel()?.type === channel.type"
(click)="selectedChannelChange.emit(channel)">
<span class="channel-name">
@for (segment of getDisplayName(channel); track $index) {
<span [style.color]="segment.color">{{ segment.text }}</span>
}
</span>
@if (channel.unreadMessages > 0) {
<div class="notification">
<svg-circle></svg-circle>
</div>
}
</div>
}
}
}
</div>
</div>
@@ -0,0 +1,59 @@
.channel-list {
height: 100%;
background-color: #172133;
display: flex;
flex-direction: column;
min-height: 0;
.channel {
position: relative;
display: flex;
align-items: center;
padding: 10px 20px;
border-bottom: 1px solid #222;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #202c42;
}
&.selected {
background-color: #202c42;
}
.channel-name {
color: white;
font-weight: bold;
}
.notification {
position: absolute;
right: 15px;
color: #3b82f6;
}
}
.channel.indented {
padding-left: 40px;
}
.channel-group-title {
padding: 10px 20px;
color: #888;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
cursor: pointer;
user-select: none;
}
.scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
box-sizing: border-box;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
}
}
@@ -0,0 +1,60 @@
import {Component, computed, effect, inject, input, output, signal} from '@angular/core';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg';
import {NameFormatService} from '@pages/altitude/chat/service/name-format.service';
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
@Component({
selector: 'app-channel-list',
imports: [
CircleSvgComponent
],
templateUrl: './channel-list.component.html',
styleUrl: './channel-list.component.scss'
})
export class ChannelListComponent {
protected readonly nameFormatService: NameFormatService = inject(NameFormatService);
public readonly channels = input.required<ChatChannel[]>();
public readonly selectedChannel = input.required<ChatChannel | null>();
public readonly selectedChannelChange = output<ChatChannel>();
protected readonly collapsedGroups = signal<Set<string>>(new Set());
protected toggleGroup(type: string) {
const next = new Set(this.collapsedGroups());
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
this.collapsedGroups.set(next);
}
protected readonly groupedChannels = computed(() => {
const channels = this.channels();
const groups: { type: ChatChannel['type'], channels: ChatChannel[] }[] = [];
const types: ChatChannel['type'][] = ['SERVER', 'DM', 'PARTY', 'GAC', 'SPY'];
for (const type of types) {
const typeChannels = channels.filter(c => c.type === type);
if (typeChannels.length > 0) {
groups.push({type, channels: typeChannels});
}
}
return groups;
});
constructor() {
effect(() => {
const chatChannels = this.channels();
if (this.selectedChannel() === null && chatChannels.length > 0) {
this.selectedChannelChange.emit(chatChannels[0]);
}
});
}
protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
return this.nameFormatService.getDisplayName(chatChannel);
}
}
@@ -1,6 +1,26 @@
@if (selectedChannel(); as chatChannel) {
<div class="full-width" <div class="full-width"
[style.background-color]="getServerColor(selectedServer())"> [style.background-color]="getServerColor(chatChannel.name)">
<span class="server" [style.color]="getServerTextColor(selectedServer())"> <span class="channel" [style.color]="getServerTextColor(chatChannel.name)">
{{ selectedServer() }} @for (segment of getDisplayName(chatChannel); track $index) {
<span [style.color]="segment.color">{{ segment.text }}</span>
}
<span class="message-count">({{ chatChannel.totalMessages }})</span>
</span> </span>
<div class="user-list-toggle" (click)="toggleUserList()" [class.active]="userListVisible()">
<svg-users [style.color]="getServerTextColor(chatChannel.name)"/>
@if (isMobile()) {
<span [style.color]="getServerTextColor(chatChannel.name)">
{{ userListVisible() ? 'Channels' : 'Users' }}
</span>
}
</div> </div>
<div class="notification-icon">
@if (notificationEnabled()) {
<svg-notification-enabled (click)="disableNotifications()" [style.color]="getServerTextColor(chatChannel.name)"/>
} @else {
<svg-notification-disabled (click)="enableNotifications()" [style.color]="getServerTextColor(chatChannel.name)"/>
}
</div>
</div>
}
@@ -1,6 +1,59 @@
.server { .full-width {
padding-left: 10px; display: flex;
font-size: 25px; align-items: center;
font-family: minecraft-text, 'opensans-bold', sans-serif; padding-top: 15px;
letter-spacing: 2px; padding-bottom: 15px;
border-radius: 8px;
margin-bottom: 10px;
}
.channel {
font-size: 20px;
font-family: minecraft-text, 'opensans-bold', sans-serif;
letter-spacing: 1px;
font-weight: bold;
padding-left: 10px;
.message-count {
font-family: 'opensans-bold', sans-serif;
padding-left: 5px;
}
}
.user-list-toggle {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
margin-right: 15px;
opacity: 0.8;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
span {
font-weight: bold;
font-size: 0.9rem;
text-transform: uppercase;
}
}
.notification-icon {
cursor: pointer;
width: 20px;
height: 20px;
margin-right: 15px;
opacity: 0.8;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
} }
@@ -1,14 +1,54 @@
import {Component, input} from '@angular/core'; import {Component, computed, inject, input} from '@angular/core';
import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/server-color'; import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/server-color';
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
import {NotificationEnabledSvgComponent} from '@pages/altitude/chat/svg/notification-enabled.svg';
import {NotificationDisabledSvgComponent} from '@pages/altitude/chat/svg/notification-disabled.svg';
import {UsersSvgComponent} from '@pages/altitude/chat/svg/users.svg';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
import {NameFormatService} from '@pages/altitude/chat/service/name-format.service';
import {ChatService} from '@pages/altitude/chat/service/chat.service';
@Component({ @Component({
selector: 'app-chat-info', selector: 'app-chat-info',
imports: [], imports: [
NotificationEnabledSvgComponent,
NotificationDisabledSvgComponent,
UsersSvgComponent
],
templateUrl: './chat-info.component.html', templateUrl: './chat-info.component.html',
styleUrl: './chat-info.component.scss' styleUrl: './chat-info.component.scss'
}) })
export class ChatInfoComponent { export class ChatInfoComponent {
public readonly selectedServer = input.required<string>(); private readonly notificationService: NotificationService = inject(NotificationService);
private readonly nameFormatService: NameFormatService = inject(NameFormatService);
private readonly chatService: ChatService = inject(ChatService);
public readonly selectedChannel = input.required<ChatChannel | null>();
public readonly isMobile = input<boolean>(false);
protected readonly userListVisible = this.chatService.userListVisible;
protected readonly channelKey = computed(() => {
const channel = this.selectedChannel();
return channel ? `${channel.type}:${channel.name}` : '';
});
protected readonly notificationEnabled = computed(() => this.notificationService.isNotificationEnabled(this.channelKey()));
protected readonly getServerColor = getServerColor; protected readonly getServerColor = getServerColor;
protected readonly getServerTextColor = getServerTextColor; protected readonly getServerTextColor = getServerTextColor;
public enableNotifications() {
this.notificationService.enableNotifications(this.channelKey());
}
public disableNotifications() {
this.notificationService.disableNotifications(this.channelKey());
}
public toggleUserList() {
this.chatService.toggleUserList();
}
protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
return this.nameFormatService.getDisplayName(chatChannel);
}
} }
@@ -1,8 +1,36 @@
<div class="message-container">
@for (message of messages(); track message.timestamp) { @for (message of messages(); track message.timestamp) {
<p> <p class="message">
<span class="date-time" [class.blocked]="!message.notBlocked"> <span class="date-time" [class.blocked]="!message.notBlocked">
[{{ message.timestamp * 1000 | date: 'mediumTime' }}] [{{ message.timestamp | date: 'mediumTime' }}]
</span> </span>
<mini-message [node]="message.messageJson"></mini-message> @defer (on viewport; prefetch on idle) {
<mini-message [node]="message.miniMessageNode"></mini-message>
} @placeholder {
<span></span>
}
</p> </p>
} }
</div>
@if (canChat()) {
<div class="chat-input-container">
<input
type="text"
[(ngModel)]="chatText"
(keyup.enter)="sendMessage()"
placeholder="Type a message..."
maxlength="256"
[disabled]="hiddenMode()"
/>
<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>
}
@@ -1,3 +1,41 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.message-container {
flex: 1 1 auto;
display: flex;
flex-direction: column-reverse;
min-height: 0;
overflow-y: auto;
box-sizing: border-box;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
}
.message-container::-webkit-scrollbar {
width: 8px;
}
.message-container::-webkit-scrollbar-track {
background: transparent;
}
.message-container::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.35);
border-radius: 999px;
}
.message-container::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.5);
}
.message {
margin: 5px 0;
}
.blocked { .blocked {
color: red; color: red;
} }
@@ -5,3 +43,60 @@
.date-time { .date-time {
color: gray; color: gray;
} }
.chat-input-container {
flex: 0 0 auto;
padding: 10px;
display: flex;
gap: 10px;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.1);
input {
flex: 1 1 auto;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 8px 12px;
color: white;
outline: none;
font-size: 14px;
&:focus {
border-color: #4f8cff;
background: rgba(255, 255, 255, 0.1);
}
&::placeholder {
color: rgba(255, 255, 255, 0.3);
}
}
button {
cursor: pointer;
border: 0;
border-radius: 4px;
padding: 8px 16px;
background: #4f8cff;
color: #ffffff;
font-weight: 700;
transition: background 0.2s;
&:hover {
background: #3b7dff;
}
&:active {
background: #2a6ae0;
}
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
&.visibility-toggle.active {
background: #b34747;
}
}
}
@@ -1,17 +1,79 @@
import {Component, input} from '@angular/core'; import {Component, computed, inject, input} from '@angular/core';
import {DatePipe} from '@angular/common'; import {DatePipe} from '@angular/common';
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object'; import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
import {MiniMessageComponent} from '@pages/altitude/chat/components/mini-message/mini-message.component'; import {MiniMessageComponent} from '@pages/altitude/chat/components/mini-message/mini-message.component';
import {AuthService} from '@services/auth.service';
import {ChatService} from '@pages/altitude/chat/service/chat.service';
import {ServerMessageService} from '@api';
import {FormsModule} from '@angular/forms';
import {MatSnackBar} from '@angular/material/snack-bar';
@Component({ @Component({
selector: 'app-chat-box', selector: 'app-chat-box',
standalone: true,
imports: [ imports: [
MiniMessageComponent, MiniMessageComponent,
DatePipe DatePipe,
FormsModule
], ],
templateUrl: './chat-box.component.html', templateUrl: './chat-box.component.html',
styleUrl: './chat-box.component.scss' styleUrl: './chat-box.component.scss'
}) })
export class ChatBoxComponent { export class ChatBoxComponent {
private readonly authService = inject(AuthService);
private readonly chatService = inject(ChatService);
private readonly serverMessageService = inject(ServerMessageService);
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 = '';
readonly canChat = computed(() => {
const hasPrivilege = this.authService.hasAccess(['SCOPE_head_mod']);
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;
}
if (message.length > 256) {
this.matSnackBar.open('Message too long, it can only be 256 characters', 'Dismiss', {
duration: 3000
});
return;
}
const selectedChannel = this.chatService.selectedChannel();
const uuid = this.authService.getUuid();
if (selectedChannel?.type === 'SERVER' && uuid) {
this.serverMessageService.sendServerMessage(selectedChannel.name, {
uuid,
message
}).subscribe({
next: () => {
this.chatText = '';
},
error: (err) => {
this.matSnackBar.open('Failed to send message', 'Dismiss', {
duration: 3000
});
}
});
}
}
toggleHiddenMode(): void {
this.chatService.toggleHiddenMode();
}
} }
@@ -1,31 +1,28 @@
@if (node) { @if (node) {
<ng-container> @if (node.isUrlLink) {
<a <a
*ngIf="isUrlLink; else plain" [style]="node.ngStyle"
[ngStyle]="ngStyle" [mmObfuscated]="node.style.obfuscated"
[mmObfuscated]="style.obfuscated" [attr.title]="node.hoverTitle"
[attr.title]="hoverTitle"
[attr.href]="node.clickEvent?.value" [attr.href]="node.clickEvent?.value"
[attr.data-insertion]="node.insertion || null" [attr.data-insertion]="node.insertion || null"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="mm-node mm-link" class="mm-node mm-link"
>{{ text }}</a> >{{ node.text }}</a>
} @else {
<ng-template #plain>
<span <span
[ngStyle]="ngStyle" [style]="node.ngStyle"
[mmObfuscated]="style.obfuscated" [mmObfuscated]="node.style.obfuscated"
[attr.title]="hoverTitle" [attr.title]="node.hoverTitle"
[attr.data-insertion]="node.insertion || null" [attr.data-insertion]="node.insertion || null"
[class.mm-clickable]="hasNonUrlClick" [class.mm-clickable]="node.hasNonUrlClick"
class="mm-node" class="mm-node"
(click)="onClick()" (click)="onClick()"
>{{ text }}</span >{{ node.text }}</span>
> }
</ng-template>
@for (child of node.extra; track child) { @for (child of node.extra; track child) {
<mini-message [node]="child" [parentStyle]="style"></mini-message> <mini-message [node]="child"></mini-message>
} }
</ng-container>
} }
@@ -1,7 +1,6 @@
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import {Component, Input, Optional} from '@angular/core'; import {Component, Input, Optional} from '@angular/core';
import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from '../../mini-message/mini-message.types'; import {ProcessedMiniMessageNode} from '../../mini-message/mini-message.types';
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from '../../mini-message/mini-message.util';
import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive'; import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive';
import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service'; import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service';
@@ -10,7 +9,7 @@ import {MiniMessageInteractionService} from '../../mini-message/mini-message-int
* *
* Usage: * Usage:
* providers: [MiniMessageInteractionService] // once, at the root that hosts <mini-message> * providers: [MiniMessageInteractionService] // once, at the root that hosts <mini-message>
* <mini-message [node]="myComponentJson"></mini-message> * <mini-message [node]="myProcessedComponentJson"></mini-message>
* *
* Then subscribe to click events (run_command / suggest_command / copy_to_clipboard / * Then subscribe to click events (run_command / suggest_command / copy_to_clipboard /
* change_page) via the injected MiniMessageInteractionService.clicks$; open_url is handled * change_page) via the injected MiniMessageInteractionService.clicks$; open_url is handled
@@ -24,42 +23,17 @@ import {MiniMessageInteractionService} from '../../mini-message/mini-message-int
styleUrl: './mini-message.component.scss', styleUrl: './mini-message.component.scss',
}) })
export class MiniMessageComponent { export class MiniMessageComponent {
@Input() node!: MiniMessageNode; @Input() node!: ProcessedMiniMessageNode;
@Input() parentStyle?: ResolvedStyle;
constructor(@Optional() private readonly interaction: MiniMessageInteractionService | null) { constructor(@Optional() private readonly interaction: MiniMessageInteractionService | null) {
} }
get style(): ResolvedStyle {
return mergeStyle(this.parentStyle, this.node);
}
get ngStyle(): Record<string, string> {
return styleToNgStyle(this.style);
}
get text(): string {
return ownText(this.node);
}
get hoverTitle(): string | null {
return hoverEventToTitle(this.node.hoverEvent);
}
get isUrlLink(): boolean {
return this.node.clickEvent?.action === 'open_url';
}
get hasNonUrlClick(): boolean {
return !!this.node.clickEvent && this.node.clickEvent.action !== 'open_url';
}
onClick(): void { onClick(): void {
const click = this.node.clickEvent; const click = this.node.clickEvent;
if (!click) return; if (!click) return;
if (click.action === 'copy_to_clipboard') { if (click.action === 'copy_to_clipboard') {
navigator.clipboard?.writeText(click.value); navigator.clipboard?.writeText(click.value).then();
} }
this.interaction?.emitClick({action: click.action, value: click.value}); this.interaction?.emitClick({action: click.action, value: click.value});
@@ -1,13 +0,0 @@
<div class="server-list">
<p>Servers</p>
@for (server of servers(); track server.server) {
<div class="server" (click)="selectedServerChange.emit(server.server)">
<span class="server-name">{{ server.server }}</span>
@if (server.unreadMessages > 0) {
<div class="notification">
<svg-circle></svg-circle>
</div>
}
</div>
}
</div>
@@ -1,23 +0,0 @@
.server-list {
height: 100%;
background-color: #172133;
.server {
position: relative;
display: flex;
padding: 2px 2px 2px 10px;
border-bottom: 1px solid #222;
.server-name {
color: white
}
.notification {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: blue;
}
}
}
@@ -1,27 +0,0 @@
import {Component, effect, input, output} from '@angular/core';
import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object';
import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg';
@Component({
selector: 'app-server-list',
imports: [
CircleSvgComponent
],
templateUrl: './server-list.component.html',
styleUrl: './server-list.component.scss'
})
export class ServerListComponent {
public readonly servers = input.required<ChatServer[]>();
public readonly selectedServer = input.required<string>();
public readonly selectedServerChange = output<string>();
constructor() {
effect(() => {
const chatServers = this.servers();
if (this.selectedServer() === '' && chatServers.length > 0) {
this.selectedServerChange.emit(chatServers[0].server);
}
});
}
}
@@ -0,0 +1,14 @@
<div class="user-list">
<div class="scroll">
@for (server of serverState()?.servers; track server.name) {
<p class="server-title">{{ server.name }} ({{ server.players.length }})</p>
@for (player of server.players; track player.uuid) {
<div class="user indented">
<span class="user-name">
<mini-message [node]="getDisplayName(player)"></mini-message>
</span>
</div>
}
}
</div>
</div>
@@ -0,0 +1,47 @@
.user-list {
height: 100%;
background-color: #172133;
display: flex;
flex-direction: column;
min-height: 0;
.user {
position: relative;
display: flex;
align-items: center;
padding: 8px 20px;
border-bottom: 1px solid #222;
cursor: default;
.user-name {
color: white;
font-weight: bold;
}
}
.user.indented {
padding-left: 40px;
}
.server-title {
padding: 10px 20px;
color: #888;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
user-select: none;
}
p {
margin: 0;
}
.scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
box-sizing: border-box;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
}
}
@@ -0,0 +1,38 @@
import {Component, computed, inject} from '@angular/core';
import {ChatService} from '@pages/altitude/chat/service/chat.service';
import {NameFormatService} from '@pages/altitude/chat/service/name-format.service';
import {User} from '@pages/altitude/chat/objects/server-state.object';
import {ProcessedMiniMessageNode} from '@pages/altitude/chat/mini-message/mini-message.types';
import {MiniMessageComponent} from '@pages/altitude/chat/components/mini-message/mini-message.component';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [MiniMessageComponent],
templateUrl: './user-list.component.html',
styleUrl: './user-list.component.scss'
})
export class UserListComponent {
private readonly chatService = inject(ChatService);
private readonly nameFormatService = inject(NameFormatService);
protected readonly serverState = computed(() => {
const state = this.chatService.serverState();
if (!state) {
return null;
}
return {
servers: [...state.servers]
.sort((a, b) => a.name.localeCompare(b.name))
.map(server => ({
...server,
players: [...server.players].sort((a, b) => a.name.localeCompare(b.name))
}))
};
});
protected getDisplayName(user: User): ProcessedMiniMessageNode {
return this.nameFormatService.getUserDisplayName(user);
}
}
@@ -73,6 +73,19 @@ export interface MiniMessageComponent {
extra?: MiniMessageComponent[]; extra?: MiniMessageComponent[];
} }
/** Fully resolved node, pre-computed for rendering */
export interface ProcessedMiniMessageNode {
text: string;
style: ResolvedStyle;
ngStyle: Record<string, string>;
hoverTitle: string | null;
isUrlLink: boolean;
hasNonUrlClick: boolean;
clickEvent?: MiniMessageClickEvent;
insertion?: string;
extra: ProcessedMiniMessageNode[];
}
/** Fully resolved style at a given node, after inheriting from all ancestors */ /** Fully resolved style at a given node, after inheriting from all ancestors */
export interface ResolvedStyle { export interface ResolvedStyle {
color?: string; color?: string;
@@ -1,4 +1,9 @@
import { MiniMessageComponent, MiniMessageHoverEvent, ResolvedStyle } from './mini-message.types'; import {
MiniMessageComponent,
MiniMessageHoverEvent,
ProcessedMiniMessageNode,
ResolvedStyle
} from './mini-message.types';
/** The 16 legacy Minecraft color names, as used by NamedTextColor / MiniMessage */ /** The 16 legacy Minecraft color names, as used by NamedTextColor / MiniMessage */
const NAMED_COLORS: Record<string, string> = { const NAMED_COLORS: Record<string, string> = {
@@ -132,3 +137,25 @@ export function hoverEventToTitle(hover: MiniMessageHoverEvent | undefined): str
} }
return null; return null;
} }
/**
* Pre-computes a component tree into a ProcessedMiniMessageNode tree.
* This resolves all styles, texts, and attributes once so they don't have to be
* re-computed on every render.
*/
export function precomputeNode(node: MiniMessageComponent, parentStyle?: ResolvedStyle): ProcessedMiniMessageNode {
const style = mergeStyle(parentStyle, node);
const isUrlLink = node.clickEvent?.action === 'open_url';
return {
text: ownText(node),
style,
ngStyle: styleToNgStyle(style),
hoverTitle: hoverEventToTitle(node.hoverEvent),
isUrlLink,
hasNonUrlClick: !!node.clickEvent && !isUrlLink,
clickEvent: node.clickEvent,
insertion: node.insertion,
extra: (node.extra ?? []).map((child) => precomputeNode(child, style)),
};
}
@@ -0,0 +1,6 @@
export interface ChatChannel {
name: string;
type: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY';
unreadMessages: number;
totalMessages: number;
}
@@ -1,11 +1,14 @@
import {MiniMessageComponent} from '@pages/altitude/chat/mini-message/mini-message.types'; import {ProcessedMiniMessageNode} from '@pages/altitude/chat/mini-message/mini-message.types';
export interface ChatMessage { export interface ChatMessage {
uuid: string; uuid: string;
timestamp: number; timestamp: number;
server: string; server: string;
//TODO [Stijn] [2026-07-18]: Handle channel types type: string;
//channel: Channel; channel: string;
messageJson: MiniMessageComponent; receiver: string;
miniMessageNode: ProcessedMiniMessageNode;
notBlocked: boolean; notBlocked: boolean;
channelName?: string;
channelType?: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY';
} }
@@ -1,4 +0,0 @@
export interface ChatServer {
server: string;
unreadMessages: number;
}
@@ -0,0 +1,4 @@
export interface ColoredNameSegment {
text: string;
color?: string;
}
@@ -0,0 +1,14 @@
export interface User {
uuid: string;
name: string;
styledName: string;
}
export interface Server {
name: string;
players: User[];
}
export interface ServerState {
servers: Server[];
}
@@ -0,0 +1,81 @@
import {Injectable, signal} from '@angular/core';
@Injectable({providedIn: 'root'})
export class NotificationService {
private readonly notificationSound = new Audio('/public/sounds/notification.mp3');
private readonly notificationStorageKey = 'chat-notifications-enabled';
private readonly _notificationEnabled = signal<Record<string, boolean>>(
this.getStoredNotificationSettings()
);
public notify(channel: string) {
if (!this.isNotificationEnabled(channel)) {
return;
}
this.playSound();
}
private enableSound() {
this.notificationSound.muted = true;
this.notificationSound.play()
.then(() => {
this.notificationSound.pause();
this.notificationSound.currentTime = 0;
this.notificationSound.muted = false;
})
.catch((error) => {
console.log('Could not unlock notification sound:', error);
this.notificationSound.muted = false;
});
}
private playSound() {
this.notificationSound.currentTime = 0;
this.notificationSound.play().catch((error) => {
console.log('Could not play notification sound:', error);
});
}
public isNotificationEnabled(channel: string): boolean {
return this._notificationEnabled()[channel];
}
public enableNotifications(channel: string) {
this.setNotificationEnabled(channel, true);
this.enableSound()
}
public disableNotifications(channel: string) {
this.setNotificationEnabled(channel, false);
}
private setNotificationEnabled(channel: string, enabled: boolean) {
const notificationSettings = {
...this._notificationEnabled(),
[channel]: enabled
};
this._notificationEnabled.set(notificationSettings);
localStorage.setItem(this.notificationStorageKey, JSON.stringify(notificationSettings));
}
private getStoredNotificationSettings(): Record<string, boolean> {
const storedSettings = localStorage.getItem(this.notificationStorageKey);
if (storedSettings === null) {
return {};
}
if (storedSettings === 'true' || storedSettings === 'false') {
return {};
}
try {
return JSON.parse(storedSettings) as Record<string, boolean>;
} catch {
return {};
}
}
}
@@ -1,15 +1,16 @@
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';
import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object'; import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object';
import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util'; import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util';
import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object'; import {precomputeNode} from '@pages/altitude/chat/mini-message/mini-message.util';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
export interface ChatEvent { import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
type: string; import {ChatInfoService} from '@api';
data: any; import {ServerState} from '@pages/altitude/chat/objects/server-state.object';
} import {CookieService} from 'ngx-cookie-service';
interface SsePayloadEvent { interface SsePayloadEvent {
data: string; data: string;
@@ -17,14 +18,45 @@ 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 authService: AuthService = inject(AuthService) 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 _messages = signal<ChatMessage[]>([])
public readonly messages = computed(() => this._messages().filter((message) => message.server === this._selectedServer())); private readonly _channels = signal<ChatChannel[]>([])
private readonly _servers = signal<ChatServer[]>([]) public readonly channels = computed(() => this._channels().sort((a, b) => a.name.localeCompare(b.name)));
public readonly servers = computed(() => this._servers().sort((a, b) => a.server.localeCompare(b.server))); private readonly _selectedChannel = signal<ChatChannel | null>(null);
private readonly _selectedServer = signal<string>(''); public readonly selectedChannel = this._selectedChannel.asReadonly()
public readonly selectedServer = this._selectedServer.asReadonly() private readonly _serverState = signal<ServerState | null>(null);
public readonly serverState = this._serverState.asReadonly();
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>();
public toggleUserList(): void {
this.userListVisible.update(v => !v);
}
public readonly messages = computed(() => {
const selected = this._selectedChannel();
if (!selected) return [];
return this._messages()
.filter((message) => message.channelName === selected.name && message.channelType === selected.type)
.sort((a, b) => b.timestamp - a.timestamp);
});
connect() { connect() {
if (this.eventSource) { if (this.eventSource) {
@@ -48,44 +80,219 @@ 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) => {
const chatMessages = this.processChatMessages(event); const chatMessages = this.processChatMessages(event);
chatMessages.forEach((message) => { chatMessages.forEach((message) => {
const find = this.servers().find(server => message.server === server.server); const selected = this._selectedChannel();
const isSelected = selected && selected.name === message.channelName && selected.type === message.channelType;
const find = this._channels().find(channel => message.channelName === channel.name && message.channelType === channel.type && !isSelected);
if (find) { if (find) {
find.unreadMessages++; find.unreadMessages++;
} }
}) })
new Set(chatMessages.map(message => `${message.channelType}:${message.channelName}`))
.forEach((channelKeyStr) => {
this.notificationService.notify(channelKeyStr);
});
});
this.on(source, 'server-state', (event) => {
const state = JSON.parse(event.data) as ServerState;
this._serverState.set(state);
}); });
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) => {
const message: ChatMessage = {
...m, ...m,
messageJson: normalizeComponent(JSON.parse(m.messageJson)), miniMessageNode: precomputeNode(normalizeComponent(JSON.parse(m.messageJson))),
})); } as ChatMessage;
const channelKey = this.getChannelKey(message);
if (channelKey === null) {
return null;
}
message.channelName = channelKey.name;
message.channelType = channelKey.type;
return message;
}).filter((message) => message !== null) as ChatMessage[];
this._messages.update((old) => [...old, ...messages]); this._messages.update((old) => [...old, ...messages]);
//TODO [Stijn] [2026-07-19]: Handle servers by getting them from backend messages.forEach((message) => {
messages.map((message) => message.server).forEach((server) => { const found = this._channels().find(existing => existing.name === message.channelName && existing.type === message.channelType);
const found = this._servers().find(existing => existing.server === server);
if (!found) { if (!found) {
this._servers.update((old) => [...old, {server: server, unreadMessages: 0}]); this._channels.update((old) => [...old, {
name: message.channelName!,
type: message.channelType!,
unreadMessages: 0,
totalMessages: 1
}]);
} else {
found.totalMessages++;
} }
}) })
return messages; return messages;
} }
public selectServer(server: string) { private getChannelKey(message: ChatMessage): { name: string, type: ChatChannel['type'] } | null {
this._selectedServer.set(server); if (message.type === 'GAC') {
return {name: 'Global Admin Chat', type: 'GAC'};
}
if (message.type === 'PARTY') {
if (!message.channel) {
throw new Error('Party name is missing');
}
this.updatePartyMap(message.channel)
return {name: message.channel, type: 'PARTY'};
}
if (message.type === 'MSG') {
const uuid = this.authService.getUuid();
if (message.receiver === uuid) {
this.updateUserNameMap(message.uuid)
return {name: message.uuid, type: 'DM'};
}
this.updateUserNameMap(message.receiver)
return {name: message.receiver, type: 'SPY'};
}
return {name: message.server, type: 'SERVER'};
}
public updatePartyMap(partyId: string): void {
if (this.partieMap().has(partyId) || this.pendingPartyNameRequests.has(partyId)) {
return;
}
this.pendingPartyNameRequests.add(partyId);
this.chatInfoService.getPartyName(partyId).subscribe({
next: (partyName) => {
this.partieMap.update((old) => {
const newMap = new Map(old);
newMap.set(partyId, partyName.name);
return newMap;
});
},
error: (error) => {
console.error(`Failed to load party name for ${partyId}:`, error);
},
complete: () => {
this.pendingPartyNameRequests.delete(partyId);
}
});
}
public updateUserNameMap(uuid: string): void {
if (this.userNameMap().has(uuid) || this.pendingUserNameRequests.has(uuid)) {
return;
}
this.pendingUserNameRequests.add(uuid);
this.chatInfoService.getName(uuid).subscribe({
next: (userName) => {
this.userNameMap.update((old) => {
const newMap = new Map(old);
newMap.set(uuid, userName.nickname ?? userName.name);
return newMap;
});
},
error: (error) => {
console.error(`Failed to load username for ${uuid}:`, error);
},
complete: () => {
this.pendingUserNameRequests.delete(uuid);
}
});
}
public selectChannel(channel: ChatChannel) {
this._selectedChannel.set(channel);
this._channels.update((old) =>
old.map((c) =>
c.name === channel.name && c.type === channel.type
? {...c, unreadMessages: 0}
: c
)
);
} }
// single, deliberate escape hatch from the broken DOM/polyfill type overlap — // single, deliberate escape hatch from the broken DOM/polyfill type overlap —
@@ -98,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 {
@@ -0,0 +1,37 @@
import {TestBed} from '@angular/core/testing';
import {NameFormatService} from './name-format.service';
import {ChatService} from './chat.service';
describe('NameFormatService', () => {
let service: NameFormatService;
let chatServiceSpy: jasmine.SpyObj<ChatService>;
beforeEach(() => {
chatServiceSpy = jasmine.createSpyObj('ChatService', ['partieMap', 'userNameMap']);
TestBed.configureTestingModule({
providers: [
NameFormatService,
{provide: ChatService, useValue: chatServiceSpy}
]
});
service = TestBed.inject(NameFormatService);
});
it('should parse legacy color codes', () => {
const input = '&4Hello &cWorld';
const result = (service as any).parseColoredName(input);
expect(result).toEqual([
{text: 'Hello ', color: '#AA0000'},
{text: 'World', color: '#FF5555'}
]);
});
it('should parse mixed color codes', () => {
const input = '{#AA0000<>}Hello &4World';
const result = (service as any).parseColoredName(input);
expect(result).toEqual([
{text: 'Hello ', color: '#AA0000'},
{text: 'World', color: '#AA0000'}
]);
});
});
@@ -0,0 +1,170 @@
import {inject, Injectable} from '@angular/core';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
import {ChatService} from "./chat.service";
import {User} from '@pages/altitude/chat/objects/server-state.object';
import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util';
import {precomputeNode} from '@pages/altitude/chat/mini-message/mini-message.util';
import {ProcessedMiniMessageNode} from '@pages/altitude/chat/mini-message/mini-message.types';
@Injectable({providedIn: 'root'})
export class NameFormatService {
private readonly chatService: ChatService = inject(ChatService);
public getDisplayName(channel: ChatChannel): ColoredNameSegment[] {
return this.parseColoredName(this.getNameFromType(channel));
}
public getUserDisplayName(user: User): ProcessedMiniMessageNode {
if (user.styledName) {
try {
return precomputeNode(normalizeComponent(JSON.parse(user.styledName)));
} catch {
return precomputeNode(normalizeComponent({text: user.name}));
}
}
return precomputeNode(normalizeComponent({text: user.name}));
}
private readonly LEGACY_COLOR_MAP: Record<string, string> = {
'4': '#AA0000',
'c': '#FF5555',
'6': '#FFAA00',
'e': '#FFFF55',
'2': '#00AA00',
'a': '#55FF55',
'b': '#55FFFF',
'3': '#00AAAA',
'1': '#0000AA',
'9': '#5555FF',
'd': '#FF55FF',
'5': '#AA00AA',
'f': '#FFFFFF',
'7': '#AAAAAA',
'8': '#555555',
'0': '#000000'
};
private getNameFromType(channel: ChatChannel): string {
if (channel.type === 'PARTY') {
return this.chatService.partieMap().get(channel.name) ?? channel.name;
}
if (channel.type === 'DM' || channel.type === 'SPY') {
return this.chatService.userNameMap().get(channel.name) ?? channel.name;
}
if (channel.type === 'SERVER') {
return channel.name.charAt(0).toUpperCase() + channel.name.slice(1);
}
return channel.name;
}
private parseColoredName(name: string): ColoredNameSegment[] {
const markerRegex = /\{#([0-9a-fA-F]{6})(<>|[<>]?)\}|&([0-9a-fA-F])/g;
const segments: ColoredNameSegment[] = [];
let currentColor: string | undefined;
let gradientStartColor: string | undefined;
let gradientStartSegmentIndex: number | undefined;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = markerRegex.exec(name)) !== null) {
this.addColoredTextSegment(segments, name.slice(lastIndex, match.index), currentColor);
const isLegacy = !!match[3];
const markerColor = isLegacy ? this.LEGACY_COLOR_MAP[match[3]] : `#${match[1]}`;
const markerType = isLegacy ? undefined : match[2];
if (markerType === '>') {
currentColor = markerColor;
gradientStartColor = markerColor;
gradientStartSegmentIndex = segments.length;
} else if (markerType === '<') {
if (gradientStartColor !== undefined && gradientStartSegmentIndex !== undefined) {
this.applyGradient(segments, gradientStartSegmentIndex, gradientStartColor, markerColor);
}
currentColor = markerColor;
gradientStartColor = undefined;
gradientStartSegmentIndex = undefined;
} else if (markerType === '<>') {
if (gradientStartColor !== undefined && gradientStartSegmentIndex !== undefined) {
this.applyGradient(segments, gradientStartSegmentIndex, gradientStartColor, markerColor);
}
currentColor = markerColor;
gradientStartColor = markerColor;
gradientStartSegmentIndex = segments.length;
} else {
currentColor = markerColor;
gradientStartColor = undefined;
gradientStartSegmentIndex = undefined;
}
lastIndex = markerRegex.lastIndex;
}
this.addColoredTextSegment(segments, name.slice(lastIndex), currentColor);
return segments.length > 0 ? segments : [{text: name}];
}
private addColoredTextSegment(segments: ColoredNameSegment[], text: string, color?: string): void {
if (text.length === 0) {
return;
}
segments.push({text, color});
}
private applyGradient(
segments: ColoredNameSegment[],
startSegmentIndex: number,
startColor: string,
endColor: string
): void {
const text = segments.slice(startSegmentIndex).map((segment) => segment.text).join('');
const length = text.length;
const start = this.hexToRgb(startColor);
const end = this.hexToRgb(endColor);
if (length === 0 || start === null || end === null) {
return;
}
const gradientSegments: ColoredNameSegment[] = [];
for (let i = 0; i < length; i++) {
const ratio = length === 1 ? 0 : i / (length - 1);
gradientSegments.push({
text: text[i],
color: this.rgbToHex(
start.r + (end.r - start.r) * ratio,
start.g + (end.g - start.g) * ratio,
start.b + (end.b - start.b) * ratio
)
});
}
segments.splice(startSegmentIndex, segments.length - startSegmentIndex, ...gradientSegments);
}
private hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = /^#?([0-9a-fA-F]{6})$/.exec(hex);
if (!match) {
return null;
}
const value = Number.parseInt(match[1], 16);
return {
r: (value >> 16) & 255,
g: (value >> 8) & 255,
b: value & 255
};
}
private rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b]
.map((value) => Math.round(value).toString(16).padStart(2, '0'))
.join('')}`;
}
}
@@ -0,0 +1,16 @@
import {Component} from '@angular/core';
@Component({
selector: 'svg-notification-disabled',
imports: [],
template: `
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 3L21 21M9.37747 3.56325C10.1871 3.19604 11.0827 3 12 3C13.5913 3 15.1174 3.59 16.2426 4.6402C17.3679 5.69041 18 7.11479 18 8.6C18 10.3566 18.2892 11.7759 18.712 12.9122M17 17H15M6.45339 6.46451C6.15686 7.13542 6 7.86016 6 8.6C6 11.2862 5.3238 13.1835 4.52745 14.4866C3.75616 15.7486 3.37051 16.3797 3.38485 16.5436C3.40095 16.7277 3.43729 16.7925 3.58603 16.9023C3.71841 17 4.34762 17 5.60605 17H9M9 17V18C9 19.6569 10.3431 21 12 21C13.6569 21 15 19.6569 15 18V17M9 17H15"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
})
export class NotificationDisabledSvgComponent {
}
@@ -0,0 +1,16 @@
import {Component} from '@angular/core';
@Component({
selector: 'svg-notification-enabled',
imports: [],
template: `
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.00195 17H5.60636C4.34793 17 3.71872 17 3.58633 16.9023C3.4376 16.7925 3.40126 16.7277 3.38515 16.5436C3.37082 16.3797 3.75646 15.7486 4.52776 14.4866C5.32411 13.1835 6.00031 11.2862 6.00031 8.6C6.00031 7.11479 6.63245 5.69041 7.75766 4.6402C8.88288 3.59 10.409 3 12.0003 3C13.5916 3 15.1177 3.59 16.2429 4.6402C17.3682 5.69041 18.0003 7.11479 18.0003 8.6C18.0003 11.2862 18.6765 13.1835 19.4729 14.4866C20.2441 15.7486 20.6298 16.3797 20.6155 16.5436C20.5994 16.7277 20.563 16.7925 20.4143 16.9023C20.2819 17 19.6527 17 18.3943 17H15.0003M9.00195 17L9.00031 18C9.00031 19.6569 10.3435 21 12.0003 21C13.6572 21 15.0003 19.6569 15.0003 18V17M9.00195 17H15.0003"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
})
export class NotificationEnabledSvgComponent {
}
@@ -0,0 +1,17 @@
import {Component} from '@angular/core';
@Component({
selector: 'svg-users',
standalone: true,
imports: [],
template: `
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 21V19C16 17.9391 15.5786 16.9217 14.8284 16.1716C14.0783 15.4214 13.0609 15 12 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 11C10.7091 11 12.5 9.20914 12.5 7C12.5 4.79086 10.7091 3 8.5 3C6.29086 3 4.5 4.79086 4.5 7C4.5 9.20914 6.29086 11 8.5 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23 21V19C22.9993 18.1137 22.7044 17.2525 22.1614 16.5523C21.6184 15.8521 20.8581 15.3516 20 15.13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 3.13C17.8604 3.35031 18.623 3.85071 19.1676 4.55163C19.7122 5.25254 20.0078 6.11514 20.0078 7.005C20.0078 7.89486 19.7122 8.75746 19.1676 9.45837C18.623 10.1593 17.8604 10.6597 17 10.88" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
})
export class UsersSvgComponent {
}
@@ -6,7 +6,7 @@ export function getServerColor(server: string) {
} else if (server === 'creative') { } else if (server === 'creative') {
return "#FFA500"; return "#FFA500";
} }
return "white"; return "#8a9197";
} }
export function getServerTextColor(server: string) { export function getServerTextColor(server: string) {
@@ -131,7 +131,7 @@
<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 (isAuthenticated()) {
@if (hasAccess([PermissionClaim.HEAD_MOD])) { @if (hasAccess([PermissionClaim.HEAD_MOD, PermissionClaim.MOD])) {
<li class="nav_li"><a class="nav_link2" [routerLink]="['/chat']">Chat</a></li> <li class="nav_li"><a class="nav_link2" [routerLink]="['/chat']">Chat</a></li>
} }
} }
@@ -58,7 +58,6 @@ export class AuthService {
} }
this.loginService.getUsername().subscribe({ this.loginService.getUsername().subscribe({
next: (username) => { next: (username) => {
console.log("Username retrieved: " + username.username);
this._username.set(username.username); this._username.set(username.username);
}, },
error: (error) => { error: (error) => {
@@ -1,18 +1,18 @@
import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ComponentFixture, TestBed} from '@angular/core/testing';
import { LoginComponent } from './login.component'; import {LoginDialogComponent} from './login.component';
describe('LoginComponent', () => { describe('LoginComponent', () => {
let component: LoginComponent; let component: LoginDialogComponent;
let fixture: ComponentFixture<LoginComponent>; let fixture: ComponentFixture<LoginDialogComponent>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [LoginComponent] imports: [LoginDialogComponent]
}) })
.compileComponents(); .compileComponents();
fixture = TestBed.createComponent(LoginComponent); fixture = TestBed.createComponent(LoginDialogComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
export const ALTITUDE_VERSION = "1.21.10" export const ALTITUDE_VERSION = "26.2"
export const enum THEME_MODE { export const enum THEME_MODE {
LIGHT = 'theme-light', LIGHT = 'theme-light',
+18
View File
@@ -30,6 +30,10 @@ tags:
description: All actions related to user email verification description: All actions related to user email verification
- name: site - name: site
description: Actions related to small features on the site such as displaying vote stats or pt/rank stats description: Actions related to small features on the site such as displaying vote stats or pt/rank stats
- name: chatInfo
description: All actions related to chat information
- name: server-message
description: All actions related to sending messages to servers
paths: paths:
/api/team/{team}: /api/team/{team}:
$ref: './schemas/team/team.yml#/getTeam' $ref: './schemas/team/team.yml#/getTeam'
@@ -97,3 +101,17 @@ paths:
$ref: './schemas/site/vote.yml#/VoteStats' $ref: './schemas/site/vote.yml#/VoteStats'
/api/site/get-staff-playtime/{from}/{to}: /api/site/get-staff-playtime/{from}/{to}:
$ref: './schemas/site/staff_pt.yml#/GetStaffPlaytime' $ref: './schemas/site/staff_pt.yml#/GetStaffPlaytime'
/api/site/chat/party/{id}/name:
$ref: './schemas/chat_info/chat_info.yml#/PartyName'
/api/site/chat/player/{uuid}/name:
$ref: './schemas/chat_info/chat_info.yml#/UserName'
/api/site/chat/send-message/{server}:
$ref: './schemas/server-message/server-message.yml#/sendServerMessage'
/api/site/chat/send-admin-chat/{server}:
$ref: './schemas/server-message/server-message.yml#/sendAdminChat'
/api/site/chat/send-private-message:
$ref: './schemas/server-message/server-message.yml#/sendPrivateMessage'
/api/site/chat/send-party-message:
$ref: './schemas/server-message/server-message.yml#/sendPartyMessage'
/api/site/chat/punish/{server}:
$ref: './schemas/server-message/server-message.yml#/punishPlayer'
+59 -16
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,12 +81,21 @@ 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:
- uuid - uuid
- name - name
- styledName - styledName
# TODO [Stijn] [2026-07-19]: Add who they ignore and who they are ignored by
properties: properties:
uuid: uuid:
@@ -68,25 +111,33 @@ components:
required: required:
- uuid - uuid
- message - message
- channel
- server - server
- type
- timestamp - timestamp
- blocked - blocked
properties: properties:
uuid: uuid:
type: string type: string
format: uuid format: uuid
channel:
type: string
enum:
- CHAT
- PARTY
- AC
- GAC
message: message:
type: string type: string
server: server:
type: string type: string
type:
type: string
enum:
- PUBLIC
- GLOBAL
- PARTY
- GAC
- MSG
- CUSTOM
channel:
type: string
maxLength: 36
receiver:
type: string
maxLength: 36
timestamp: timestamp:
type: string type: string
format: date-time format: date-time
@@ -132,11 +183,3 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/Server" $ref: "#/components/schemas/Server"
parameters:
From:
name: from
in: path
required: true
schema:
type: string
format: date-time
@@ -0,0 +1,64 @@
UserName:
get:
tags:
- chatInfo
summary: Gets the name, and nickname of the player associated with that uuid
operationId: getName
parameters:
- name: uuid
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: The player's name and nickname
content:
application/json:
schema:
$ref: "#/components/schemas/PlayerName"
"404":
description: Player not found
PartyName:
get:
tags:
- chatInfo
summary: Gets the name, of the party based on the id
operationId: getPartyName
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: The party name
content:
application/json:
schema:
$ref: "#/components/schemas/PartyName"
"400":
description: Invalid ID
"404":
description: Party not found
components:
schemas:
PlayerName:
type: object
required:
- name
properties:
name:
type: string
nickname:
type: string
PartyName:
type: object
required:
- name
properties:
name:
type: string
@@ -2,7 +2,7 @@ components:
schemas: schemas:
PermissionClaim: PermissionClaim:
type: string type: string
enum: [ SCOPE_user, SCOPE_head_mod ] enum: [ SCOPE_user, SCOPE_head_mod, SCOPE_mod ]
description: Permission claims used for authorization description: Permission claims used for authorization
x-enum-varnames: [ USER, HEAD_MOD ] x-enum-varnames: [ USER, HEAD_MOD, MOD ]
x-enum-descriptions: [ "User permission", "Head moderator permission" ] x-enum-descriptions: [ "User permission", "Head moderator permission" ]
@@ -0,0 +1,192 @@
sendServerMessage:
post:
tags:
- server-message
summary: Send a message to a specific server
operationId: sendServerMessage
parameters:
- $ref: '#/components/parameters/Server'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ServerMessageRequest'
responses:
'202':
description: Message accepted and sent
'404':
description: Server not found or not connected
'400':
description: Invalid request
sendAdminChat:
post:
tags:
- server-message
summary: Send an admin chat to a specific server
operationId: sendAdminChat
parameters:
- $ref: '#/components/parameters/Server'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ServerMessageRequest'
responses:
'202':
description: Message accepted and sent
'404':
description: Server not found or not connected
'400':
description: Invalid request
sendPrivateMessage:
post:
tags:
- server-message
summary: Send a private message
operationId: sendPrivateMessage
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PrivateMessageRequest'
responses:
'202':
description: Message accepted and sent
'400':
description: Invalid request
sendPartyMessage:
post:
tags:
- server-message
summary: Send a party message
operationId: sendPartyMessage
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PartyMessageRequest'
responses:
'202':
description: Message accepted and sent
'400':
description: Invalid request
punishPlayer:
post:
tags:
- server-message
summary: Punish a player on a specific server
operationId: punishPlayer
parameters:
- $ref: '#/components/parameters/Server'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PunishRequest'
responses:
'202':
description: Punishment accepted and applied
'404':
description: Server not found or not connected
'400':
description: Invalid request
components:
parameters:
Server:
name: server
in: path
required: true
schema:
type: string
description: The name of the server
schemas:
ServerMessageRequest:
type: object
required:
- uuid
- message
properties:
uuid:
type: string
format: uuid
description: The sender UUID
message:
type: string
description: The message to send
maxLength: 256
PrivateMessageRequest:
type: object
required:
- from
- message
- to
properties:
from:
type: string
format: uuid
description: The sender UUID
message:
type: string
description: The message to send
maxLength: 256
to:
type: string
format: uuid
description: The recipient UUID
PartyMessageRequest:
type: object
required:
- from
- message
- partyId
properties:
from:
type: string
format: uuid
description: The sender UUID
message:
type: string
description: The message to send
maxLength: 256
partyId:
type: string
format: int32
description: The party id
PunishRequest:
type: object
required:
- executor
- target
- type
- reason
properties:
executor:
type: string
format: uuid
description: The executor UUID
target:
type: string
format: uuid
description: The target UUID
type:
type: string
description: The type of punishment
enum:
- BAN
- MUTE
- WARN
- FLAG
reason:
type: string
description: The reason for the punishment
time:
type: string
format: duration
example: PT30S