Compare commits
25
Commits
819739ff60
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4623fbcc82 | ||
|
|
610ef2f327 | ||
|
|
bd6174dd23 | ||
|
|
8a61d35e63 | ||
|
|
2a65f98493 | ||
|
|
423d9c9043 | ||
|
|
7f94569575 | ||
|
|
1a0bc902ac | ||
|
|
8c02543bca | ||
|
|
2705cf14ab | ||
|
|
65d0118e3d | ||
|
|
b61b117f41 | ||
|
|
bdda71229d | ||
|
|
49b85bb695 | ||
|
|
ce7c68511b | ||
|
|
065db06e16 | ||
|
|
8533cfa365 | ||
|
|
d3389159b6 | ||
|
|
8849612138 | ||
|
|
9757ce2ffa | ||
|
|
4f67228c39 | ||
|
|
b71a2f19b8 | ||
|
|
f7edde65d6 | ||
|
|
90eb40caa7 | ||
|
|
c8b5a01c21 |
@@ -62,6 +62,9 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/mail/**").authenticated()
|
||||
.requestMatchers("/api/site/vote").authenticated()
|
||||
.requestMatchers("/api/appeal").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/chat/hide").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers(HttpMethod.POST, "/api/chat/show").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers(HttpMethod.GET, "/api/chat/visibility").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/chat/read/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
|
||||
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
@@ -71,6 +74,10 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/files/download/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.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()
|
||||
)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.alttd.altitudeweb.controllers.chat;
|
||||
|
||||
import com.alttd.altitudeweb.api.ChatApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.model.ChatMessageDto;
|
||||
import com.alttd.altitudeweb.model.PlayerListVisibilityDto;
|
||||
import com.alttd.altitudeweb.model.ServerStateDto;
|
||||
import com.alttd.altitudeweb.services.chat.ChatService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -11,6 +13,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@@ -18,6 +21,29 @@ import java.util.List;
|
||||
public class ChatController implements ChatApi {
|
||||
|
||||
private final ChatService chatService;
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<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
|
||||
public ResponseEntity<Void> sendChatMessages(List<ChatMessageDto> chatMessageDtoList) {
|
||||
@@ -28,7 +54,7 @@ public class ChatController implements ChatApi {
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> updateServerStates(ServerStateDto serverStateDto) {
|
||||
//TODO [Stijn] [2026-07-18]: Implement handling server state updates (push to listeners)
|
||||
throw new UnsupportedOperationException("Not implemented");
|
||||
chatService.updateServerState(serverStateDto);
|
||||
return new ResponseEntity<>(HttpStatus.ACCEPTED);
|
||||
}
|
||||
}
|
||||
|
||||
+93
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.alttd.altitudeweb.controllers.event;
|
||||
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.services.chat.ChatService;
|
||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
||||
@@ -37,7 +39,6 @@ public class EventController {
|
||||
|
||||
private final static Duration MESSAGE_HISTORY_DURATION = Duration.ofHours(1);
|
||||
private final ChatService chatService;
|
||||
private final EventPublisher eventPublisher;
|
||||
|
||||
@GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter subscribe(@AuthenticationPrincipal Jwt jwt,
|
||||
@@ -68,7 +69,8 @@ public class EventController {
|
||||
.collect(Collectors.joining(",", "[", "]"));
|
||||
|
||||
Integer partyId = getPartyId(subject);
|
||||
return eventPublisher.subscribe(new EventUser(subject, authorities, partyId), json);
|
||||
EventUser eventUser = new EventUser(subject, authorities, partyId);
|
||||
return chatService.subscribe(eventUser, json);
|
||||
}
|
||||
|
||||
private Integer getPartyId(UUID subject) {
|
||||
|
||||
@@ -2,11 +2,19 @@ package com.alttd.altitudeweb.services.chat;
|
||||
|
||||
import com.alttd.altitudeweb.controllers.chat.ChatMessage;
|
||||
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.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.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.mappers.PlayerListStateMapper;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -15,10 +23,14 @@ import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@@ -27,10 +39,15 @@ import java.util.stream.Collectors;
|
||||
public class ChatService {
|
||||
|
||||
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}")
|
||||
private String[] allowedServers;
|
||||
private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>();
|
||||
private final EventPublisher eventPublisher;
|
||||
private long lastMillis = 0;
|
||||
|
||||
@Scheduled(cron = "0 * * * * *")
|
||||
@@ -61,6 +78,86 @@ 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) {
|
||||
@@ -82,13 +179,35 @@ public class ChatService {
|
||||
sendMessagesToListeners(chatMessageList);
|
||||
}
|
||||
|
||||
public synchronized void updateServerState(ServerStateDto serverStateDto) {
|
||||
serverStateDto.getServers().forEach(serverDto -> serverStateCache.put(serverDto.getName(), serverDto));
|
||||
sendToUsers("server-state", this::getServerStateJson);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
eventPublisher.sendToUsers("chat", (eventUser) ->
|
||||
sendToUsers("chat", (eventUser) ->
|
||||
chatMessageList.stream()
|
||||
.filter(chatMessage -> shouldReceive(eventUser, chatMessage))
|
||||
.map(jsonCache::get)
|
||||
@@ -96,6 +215,19 @@ public class ChatService {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
+63
-51
@@ -1,10 +1,5 @@
|
||||
package com.alttd.altitudeweb.services.chat.event_publisher;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSession;
|
||||
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
@@ -14,30 +9,54 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Generic SSE event publisher, keyed by an arbitrary "unique" string
|
||||
* 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
|
||||
@Service
|
||||
@Slf4j
|
||||
public class EventPublisher {
|
||||
|
||||
private final Map<UUID, EventUser> eventUserMap = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, 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
|
||||
|
||||
emitterMap.computeIfAbsent(eventUser.uuid(), k -> new CopyOnWriteArrayList<>()).add(emitter);
|
||||
eventUserMap.put(eventUser.uuid(), eventUser);
|
||||
emitterMap.computeIfAbsent(uniqueKey, k -> new CopyOnWriteArrayList<>()).add(emitter);
|
||||
Instant sessionStart = Instant.now();
|
||||
log.info("User {} subscribed, active emitters: {}", eventUser.uuid(), countActive());
|
||||
log.info("Key {} subscribed, active emitters: {}", uniqueKey, countActive());
|
||||
|
||||
Runnable cleanup = () -> handleCleanup(eventUser, emitter, sessionStart);
|
||||
AtomicBoolean cleanedUp = new AtomicBoolean();
|
||||
Runnable cleanup = () -> {
|
||||
if (cleanedUp.compareAndSet(false, true)) {
|
||||
handleCleanup(uniqueKey, emitter, sessionStart, cleanupCallback);
|
||||
}
|
||||
};
|
||||
emitterCleanupMap.put(emitter, cleanup);
|
||||
|
||||
// covers: clean disconnect (tab closed normally), server-side timeout, write failure
|
||||
emitter.onCompletion(cleanup);
|
||||
@@ -45,7 +64,11 @@ public class EventPublisher {
|
||||
emitter.onError(e -> cleanup.run());
|
||||
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("connected").data(initialData));
|
||||
if (initialData != null) {
|
||||
emitter.send(SseEmitter.event().name("connected").data(initialData));
|
||||
} else {
|
||||
emitter.send(SseEmitter.event().name("connected"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
cleanup.run();
|
||||
}
|
||||
@@ -58,79 +81,68 @@ public class EventPublisher {
|
||||
@Scheduled(fixedRate = 20_000)
|
||||
public void heartbeat() {
|
||||
emitterMap.values().forEach(list -> {
|
||||
List<SseEmitter> dead = new ArrayList<>();
|
||||
for (SseEmitter emitter : list) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().comment("heartbeat"));
|
||||
} catch (IOException e) {
|
||||
dead.add(emitter);
|
||||
cleanup(emitter);
|
||||
}
|
||||
}
|
||||
list.removeAll(dead);
|
||||
});
|
||||
emitterMap.entrySet().removeIf(e -> e.getValue().isEmpty());
|
||||
}
|
||||
|
||||
public void sendToUser(UUID uuid, String eventName, String json) {
|
||||
List<SseEmitter> userEmitters = emitterMap.get(uuid);
|
||||
public void sendToUser(String uniqueKey, String eventName, String json) {
|
||||
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
|
||||
if (userEmitters == null) {
|
||||
log.warn("No emitters found for unique key: {}", uniqueKey);
|
||||
return;
|
||||
}
|
||||
|
||||
List<SseEmitter> dead = new ArrayList<>();
|
||||
for (SseEmitter emitter : userEmitters) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(eventName).data(json));
|
||||
} 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() {
|
||||
return emitterMap.values().stream().mapToLong(List::size).sum();
|
||||
}
|
||||
|
||||
public void sendToUsers(String eventName, MessageForUser messagesForUser) {
|
||||
eventUserMap.values().forEach(emitter -> {
|
||||
String json = messagesForUser.get(emitter);
|
||||
sendToUser(emitter.uuid(), eventName, json);
|
||||
});
|
||||
private void cleanup(SseEmitter emitter) {
|
||||
Runnable cleanup = emitterCleanupMap.get(emitter);
|
||||
if (cleanup != null) {
|
||||
cleanup.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCleanup(EventUser eventUser, SseEmitter emitter, Instant sessionStart) {
|
||||
List<SseEmitter> userEmitters = emitterMap.get(eventUser.uuid());
|
||||
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(eventUser.uuid());
|
||||
eventUserMap.remove(eventUser.uuid());
|
||||
emitterMap.remove(uniqueKey);
|
||||
}
|
||||
}
|
||||
Instant sessionEnd = Instant.now();
|
||||
Duration between = Duration.between(sessionStart, sessionEnd);
|
||||
log.info("Cleaned up emitter for {}, active emitters: {}, session duration: {}",
|
||||
eventUser.uuid(), countActive(), between
|
||||
uniqueKey, countActive(), between
|
||||
);
|
||||
ChatSession chatSession = ChatSession.builder()
|
||||
.uuid(eventUser.uuid())
|
||||
.session_start(sessionStart)
|
||||
.session_end(sessionEnd)
|
||||
.build();
|
||||
saveSession(chatSession);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
if (cleanupCallback != null) {
|
||||
cleanupCallback.onCleanup(uniqueKey, sessionStart, sessionEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
|
||||
}
|
||||
+16
@@ -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;
|
||||
|
||||
}
|
||||
+17
@@ -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;
|
||||
|
||||
}
|
||||
+16
@@ -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;
|
||||
|
||||
}
|
||||
+18
@@ -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;
|
||||
|
||||
}
|
||||
+18
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+18
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+19
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+18
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+20
@@ -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.tomcat.remoteip.remote-ip-header=CF-Connecting-IP
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -3,32 +3,42 @@ 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);
|
||||
chatService = new ChatService(eventPublisher);
|
||||
serverMessageService = mock(ServerMessageService.class);
|
||||
chatService = spy(new ChatService(eventPublisher, serverMessageService));
|
||||
ReflectionTestUtils.setField(chatService, "allowedServers", new String[]{"server1"});
|
||||
}
|
||||
|
||||
@@ -46,7 +56,7 @@ class ChatServiceTest {
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(eventPublisher).sendToUsers(anyString(), captor.capture());
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
|
||||
@@ -69,7 +79,7 @@ class ChatServiceTest {
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(eventPublisher).sendToUsers(anyString(), captor.capture());
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
|
||||
@@ -97,7 +107,7 @@ class ChatServiceTest {
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(eventPublisher).sendToUsers(anyString(), captor.capture());
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
|
||||
@@ -123,7 +133,7 @@ class ChatServiceTest {
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(eventPublisher).sendToUsers(anyString(), captor.capture());
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
|
||||
@@ -144,7 +154,7 @@ class ChatServiceTest {
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(eventPublisher).sendToUsers(anyString(), captor.capture());
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
|
||||
@@ -153,4 +163,77 @@ class ChatServiceTest {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -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\":[]}");
|
||||
}
|
||||
}
|
||||
@@ -24,20 +24,29 @@
|
||||
<app-full-size [hideFooter]="true">
|
||||
<section class="darkmodeSection full-height">
|
||||
<div class="page-layout">
|
||||
<div class="channel-list">
|
||||
<app-channel-list [channels]="channels()" [selectedChannel]="selectedChannel()"
|
||||
(selectedChannelChange)="onSelectedChannelChange($event)">
|
||||
@if (!isMobile() || !userListVisible()) {
|
||||
<div class="channel-list">
|
||||
<app-channel-list [channels]="channels()" [selectedChannel]="selectedChannel()"
|
||||
(selectedChannelChange)="onSelectedChannelChange($event)">
|
||||
|
||||
</app-channel-list>
|
||||
</div>
|
||||
</app-channel-list>
|
||||
</div>
|
||||
}
|
||||
<div class="chat">
|
||||
<div class="chat-info">
|
||||
<app-chat-info [selectedChannel]="selectedChannel()"></app-chat-info>
|
||||
<div class="chat-info-container">
|
||||
<app-chat-info [selectedChannel]="selectedChannel()" [isMobile]="isMobile()"></app-chat-info>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-box">
|
||||
<app-chat-box [messages]="messages()"></app-chat-box>
|
||||
</div>
|
||||
</div>
|
||||
@if (userListVisible()) {
|
||||
<div class="user-list">
|
||||
<app-user-list></app-user-list>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</app-full-size>
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -70,6 +76,16 @@
|
||||
.chat-info {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 5px 2px 5px;
|
||||
|
||||
.chat-info-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
app-chat-info {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-box {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {FullSizeComponent} from '@shared-components/full-size/full-size.componen
|
||||
import {ChatBoxComponent} from '@pages/altitude/chat/components/chat/chat-box.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 {UserListComponent} from '@pages/altitude/chat/components/user-list/user-list.component';
|
||||
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
|
||||
|
||||
@Component({
|
||||
@@ -17,7 +18,8 @@ import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
|
||||
FullSizeComponent,
|
||||
ChatBoxComponent,
|
||||
ChannelListComponent,
|
||||
ChatInfoComponent
|
||||
ChatInfoComponent,
|
||||
UserListComponent
|
||||
],
|
||||
templateUrl: './chat.component.html',
|
||||
styleUrl: './chat.component.scss',
|
||||
@@ -31,6 +33,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
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, private breakpointObserver: BreakpointObserver) {
|
||||
interaction.clicks$.subscribe(({action, value}) => {
|
||||
@@ -41,8 +44,14 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.breakpointObserver
|
||||
.observe([Breakpoints.Handset])
|
||||
.subscribe(result => {
|
||||
this.isMobile.set(result.matches);
|
||||
this.showFullscreenPrompt.set(this.isMobile());
|
||||
const isMobile = result.matches;
|
||||
this.isMobile.set(isMobile);
|
||||
this.showFullscreenPrompt.set(isMobile);
|
||||
if (isMobile) {
|
||||
this.userListVisible.set(false);
|
||||
} else {
|
||||
this.userListVisible.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +59,10 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.chatService.selectChannel(channel);
|
||||
}
|
||||
|
||||
protected toggleUserList(): void {
|
||||
this.chatService.toggleUserList();
|
||||
}
|
||||
|
||||
protected enterFullscreen(): void {
|
||||
this.showFullscreenPrompt.set(false);
|
||||
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
@if (selectedChannel(); as chatChannel) {
|
||||
<div class="full-width"
|
||||
[style.background-color]="getServerColor(chatChannel.name ?? '')">
|
||||
<span class="channel" [style.color]="getServerTextColor(chatChannel.name ?? '')">
|
||||
[style.background-color]="getServerColor(chatChannel.name)">
|
||||
<span class="channel" [style.color]="getServerTextColor(chatChannel.name)">
|
||||
@for (segment of getDisplayName(chatChannel); track $index) {
|
||||
<span [style.color]="segment.color">{{ segment.text }}</span>
|
||||
}
|
||||
<span class="message-count">({{ chatChannel.totalMessages }})</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 class="notification-icon">
|
||||
@if (notificationEnabled()) {
|
||||
<svg-notification-enabled (click)="disableNotifications()"/>
|
||||
<svg-notification-enabled (click)="disableNotifications()" [style.color]="getServerTextColor(chatChannel.name)"/>
|
||||
} @else {
|
||||
<svg-notification-disabled (click)="enableNotifications()"/>
|
||||
<svg-notification-disabled (click)="enableNotifications()" [style.color]="getServerTextColor(chatChannel.name)"/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,47 @@
|
||||
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-left: auto;
|
||||
margin-right: 10px;
|
||||
margin-right: 15px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@ import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/serv
|
||||
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({
|
||||
selector: 'app-chat-info',
|
||||
imports: [
|
||||
NotificationEnabledSvgComponent,
|
||||
NotificationDisabledSvgComponent
|
||||
NotificationDisabledSvgComponent,
|
||||
UsersSvgComponent
|
||||
],
|
||||
templateUrl: './chat-info.component.html',
|
||||
styleUrl: './chat-info.component.scss'
|
||||
@@ -19,8 +22,11 @@ import {NameFormatService} from '@pages/altitude/chat/service/name-format.servic
|
||||
export class ChatInfoComponent {
|
||||
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}` : '';
|
||||
@@ -37,6 +43,10 @@ export class ChatInfoComponent {
|
||||
this.notificationService.disableNotifications(this.channelKey());
|
||||
}
|
||||
|
||||
public toggleUserList() {
|
||||
this.chatService.toggleUserList();
|
||||
}
|
||||
|
||||
protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
|
||||
return this.nameFormatService.getDisplayName(chatChannel);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,33 @@
|
||||
<span class="date-time" [class.blocked]="!message.notBlocked">
|
||||
[{{ message.timestamp | date: 'mediumTime' }}]
|
||||
</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>
|
||||
}
|
||||
</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,12 +1,14 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: thin;
|
||||
@@ -41,3 +43,60 @@
|
||||
.date-time {
|
||||
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 {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
|
||||
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({
|
||||
selector: 'app-chat-box',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MiniMessageComponent,
|
||||
DatePipe
|
||||
DatePipe,
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './chat-box.component.html',
|
||||
styleUrl: './chat-box.component.scss'
|
||||
})
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
|
||||
+19
-22
@@ -1,31 +1,28 @@
|
||||
@if (node) {
|
||||
<ng-container>
|
||||
@if (node.isUrlLink) {
|
||||
<a
|
||||
*ngIf="isUrlLink; else plain"
|
||||
[ngStyle]="ngStyle"
|
||||
[mmObfuscated]="style.obfuscated"
|
||||
[attr.title]="hoverTitle"
|
||||
[style]="node.ngStyle"
|
||||
[mmObfuscated]="node.style.obfuscated"
|
||||
[attr.title]="node.hoverTitle"
|
||||
[attr.href]="node.clickEvent?.value"
|
||||
[attr.data-insertion]="node.insertion || null"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mm-node mm-link"
|
||||
>{{ text }}</a>
|
||||
>{{ node.text }}</a>
|
||||
} @else {
|
||||
<span
|
||||
[style]="node.ngStyle"
|
||||
[mmObfuscated]="node.style.obfuscated"
|
||||
[attr.title]="node.hoverTitle"
|
||||
[attr.data-insertion]="node.insertion || null"
|
||||
[class.mm-clickable]="node.hasNonUrlClick"
|
||||
class="mm-node"
|
||||
(click)="onClick()"
|
||||
>{{ node.text }}</span>
|
||||
}
|
||||
|
||||
<ng-template #plain>
|
||||
<span
|
||||
[ngStyle]="ngStyle"
|
||||
[mmObfuscated]="style.obfuscated"
|
||||
[attr.title]="hoverTitle"
|
||||
[attr.data-insertion]="node.insertion || null"
|
||||
[class.mm-clickable]="hasNonUrlClick"
|
||||
class="mm-node"
|
||||
(click)="onClick()"
|
||||
>{{ text }}</span
|
||||
>
|
||||
</ng-template>
|
||||
@for (child of node.extra; track child) {
|
||||
<mini-message [node]="child" [parentStyle]="style"></mini-message>
|
||||
}
|
||||
</ng-container>
|
||||
@for (child of node.extra; track child) {
|
||||
<mini-message [node]="child"></mini-message>
|
||||
}
|
||||
}
|
||||
|
||||
+4
-30
@@ -1,7 +1,6 @@
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {Component, Input, Optional} from '@angular/core';
|
||||
import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from '../../mini-message/mini-message.types';
|
||||
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from '../../mini-message/mini-message.util';
|
||||
import {ProcessedMiniMessageNode} from '../../mini-message/mini-message.types';
|
||||
import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive';
|
||||
import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service';
|
||||
|
||||
@@ -10,7 +9,7 @@ import {MiniMessageInteractionService} from '../../mini-message/mini-message-int
|
||||
*
|
||||
* Usage:
|
||||
* 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 /
|
||||
* 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',
|
||||
})
|
||||
export class MiniMessageComponent {
|
||||
@Input() node!: MiniMessageNode;
|
||||
@Input() parentStyle?: ResolvedStyle;
|
||||
@Input() node!: ProcessedMiniMessageNode;
|
||||
|
||||
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 {
|
||||
const click = this.node.clickEvent;
|
||||
if (!click) return;
|
||||
|
||||
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});
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
export interface ResolvedStyle {
|
||||
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 */
|
||||
const NAMED_COLORS: Record<string, string> = {
|
||||
@@ -132,3 +137,25 @@ export function hoverEventToTitle(hover: MiniMessageHoverEvent | undefined): str
|
||||
}
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export interface ChatChannel {
|
||||
name: string;
|
||||
type: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY';
|
||||
unreadMessages: number;
|
||||
totalMessages: number;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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 {
|
||||
uuid: string;
|
||||
@@ -7,7 +7,7 @@ export interface ChatMessage {
|
||||
type: string;
|
||||
channel: string;
|
||||
receiver: string;
|
||||
messageJson: MiniMessageComponent;
|
||||
miniMessageNode: ProcessedMiniMessageNode;
|
||||
notBlocked: boolean;
|
||||
channelName?: string;
|
||||
channelType?: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import {computed, inject, Injectable, OnDestroy, signal} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
import {EventSourcePolyfill} from 'event-source-polyfill';
|
||||
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
|
||||
import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object';
|
||||
import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util';
|
||||
import {precomputeNode} from '@pages/altitude/chat/mini-message/mini-message.util';
|
||||
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
|
||||
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
|
||||
import {ChatInfoService} from '@api';
|
||||
import {ServerState} from '@pages/altitude/chat/objects/server-state.object';
|
||||
import {CookieService} from 'ngx-cookie-service';
|
||||
|
||||
interface SsePayloadEvent {
|
||||
data: string;
|
||||
@@ -14,20 +18,38 @@ interface SsePayloadEvent {
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class ChatService implements OnDestroy {
|
||||
private readonly HIDDEN_MODE_COOKIE = 'chat-hidden-mode';
|
||||
private eventSource?: EventSourcePolyfill;
|
||||
private visibilityPollId?: number;
|
||||
private visibilityRevision = 0;
|
||||
private readonly notificationService: NotificationService = inject(NotificationService);
|
||||
private readonly authService: AuthService = inject(AuthService)
|
||||
private readonly chatInfoService: ChatInfoService = inject(ChatInfoService);
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
private readonly cookieService = inject(CookieService);
|
||||
private readonly _messages = signal<ChatMessage[]>([])
|
||||
private readonly _channels = signal<ChatChannel[]>([])
|
||||
public readonly channels = computed(() => this._channels().sort((a, b) => a.name.localeCompare(b.name)));
|
||||
private readonly _selectedChannel = signal<ChatChannel | null>(null);
|
||||
public readonly selectedChannel = this._selectedChannel.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 [];
|
||||
@@ -58,6 +80,9 @@ export class ChatService implements OnDestroy {
|
||||
|
||||
this.on(source, 'connected', (event) => {
|
||||
this.processChatMessages(event);
|
||||
this._connected.set(true);
|
||||
this.restoreVisibilityAfterConnection();
|
||||
this.startVisibilityPolling();
|
||||
});
|
||||
|
||||
this.on(source, 'chat', (event) => {
|
||||
@@ -77,18 +102,90 @@ export class ChatService implements OnDestroy {
|
||||
});
|
||||
});
|
||||
|
||||
this.on(source, 'server-state', (event) => {
|
||||
const state = JSON.parse(event.data) as ServerState;
|
||||
this._serverState.set(state);
|
||||
});
|
||||
|
||||
source.onerror = (err) => {
|
||||
this._connected.set(false);
|
||||
console.error('SSE error, polyfill will auto-reconnect:', err);
|
||||
};
|
||||
}
|
||||
|
||||
public toggleHiddenMode(): void {
|
||||
if (!this._connected() || this._visibilityUpdating()) {
|
||||
return;
|
||||
}
|
||||
this.setBackendHiddenMode(!this._hiddenMode());
|
||||
}
|
||||
|
||||
private restoreVisibilityAfterConnection(): void {
|
||||
if (this._hiddenMode()) {
|
||||
this.setBackendHiddenMode(true);
|
||||
} else {
|
||||
this.validateVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
private setBackendHiddenMode(hidden: boolean): void {
|
||||
if (!this._connected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = ++this.visibilityRevision;
|
||||
this._visibilityUpdating.set(true);
|
||||
const path = hidden ? '/api/chat/hide' : '/api/chat/show';
|
||||
this.httpClient.post<void>(path, null).subscribe({
|
||||
next: () => {
|
||||
if (revision === this.visibilityRevision) {
|
||||
this.applyHiddenMode(hidden);
|
||||
}
|
||||
this._visibilityUpdating.set(false);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(`Failed to ${hidden ? 'hide' : 'show'} chat session:`, error);
|
||||
this._visibilityUpdating.set(false);
|
||||
this.validateVisibility();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startVisibilityPolling(): void {
|
||||
if (this.visibilityPollId !== undefined) {
|
||||
return;
|
||||
}
|
||||
this.visibilityPollId = window.setInterval(() => this.validateVisibility(), 60_000);
|
||||
}
|
||||
|
||||
private validateVisibility(): void {
|
||||
if (!this._connected() || this._visibilityUpdating()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = this.visibilityRevision;
|
||||
this.httpClient.get<{hidden: boolean}>('/api/chat/visibility').subscribe({
|
||||
next: (visibility) => {
|
||||
if (revision === this.visibilityRevision) {
|
||||
this.applyHiddenMode(visibility.hidden);
|
||||
}
|
||||
},
|
||||
error: (error) => console.error('Failed to validate chat visibility:', error)
|
||||
});
|
||||
}
|
||||
|
||||
private applyHiddenMode(hidden: boolean): void {
|
||||
this._hiddenMode.set(hidden);
|
||||
this.cookieService.set(this.HIDDEN_MODE_COOKIE, String(hidden), 365, '/');
|
||||
}
|
||||
|
||||
private processChatMessages(event: SsePayloadEvent): ChatMessage[] {
|
||||
const raw = JSON.parse(event.data) as RawChatMessage[];
|
||||
const messages: ChatMessage[] = raw.map((m) => {
|
||||
const message: ChatMessage = {
|
||||
...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;
|
||||
@@ -105,8 +202,11 @@ export class ChatService implements OnDestroy {
|
||||
this._channels.update((old) => [...old, {
|
||||
name: message.channelName!,
|
||||
type: message.channelType!,
|
||||
unreadMessages: 0
|
||||
unreadMessages: 0,
|
||||
totalMessages: 1
|
||||
}]);
|
||||
} else {
|
||||
found.totalMessages++;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -205,6 +305,11 @@ export class ChatService implements OnDestroy {
|
||||
disconnect(): void {
|
||||
this.eventSource?.close();
|
||||
this.eventSource = undefined;
|
||||
this._connected.set(false);
|
||||
if (this.visibilityPollId !== undefined) {
|
||||
window.clearInterval(this.visibilityPollId);
|
||||
this.visibilityPollId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
||||
@@ -2,6 +2,10 @@ 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 {
|
||||
@@ -12,6 +16,18 @@ export class NameFormatService {
|
||||
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',
|
||||
@@ -38,6 +54,9 @@ export class NameFormatService {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {Component} from '@angular/core';
|
||||
<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="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {Component} from '@angular/core';
|
||||
<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="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export const ALTITUDE_VERSION = "1.21.10"
|
||||
export const ALTITUDE_VERSION = "26.2"
|
||||
|
||||
export const enum THEME_MODE {
|
||||
LIGHT = 'theme-light',
|
||||
|
||||
@@ -32,6 +32,8 @@ tags:
|
||||
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:
|
||||
/api/team/{team}:
|
||||
$ref: './schemas/team/team.yml#/getTeam'
|
||||
@@ -103,3 +105,13 @@ paths:
|
||||
$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'
|
||||
|
||||
@@ -12,6 +12,40 @@ tags:
|
||||
description: Data for displaying Chat messages to clients
|
||||
|
||||
paths:
|
||||
/chat/hide:
|
||||
post:
|
||||
tags:
|
||||
- chat
|
||||
summary: Hide the authenticated user from the active player list for this session
|
||||
operationId: hideUserFromPlayerList
|
||||
responses:
|
||||
"204":
|
||||
description: User hidden for the current session
|
||||
|
||||
/chat/show:
|
||||
post:
|
||||
tags:
|
||||
- chat
|
||||
summary: Show the authenticated user in the active player list for this session
|
||||
operationId: showUserInPlayerList
|
||||
responses:
|
||||
"204":
|
||||
description: User shown for the current session
|
||||
|
||||
/chat/visibility:
|
||||
get:
|
||||
tags:
|
||||
- chat
|
||||
summary: Get the authenticated user's active player list visibility
|
||||
operationId: getPlayerListVisibility
|
||||
responses:
|
||||
"200":
|
||||
description: Current visibility mode
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PlayerListVisibility"
|
||||
|
||||
/chat/send/chat/message:
|
||||
post:
|
||||
tags:
|
||||
@@ -47,12 +81,21 @@ paths:
|
||||
description: Accepted
|
||||
components:
|
||||
schemas:
|
||||
PlayerListVisibility:
|
||||
type: object
|
||||
required:
|
||||
- hidden
|
||||
properties:
|
||||
hidden:
|
||||
type: boolean
|
||||
|
||||
User:
|
||||
type: object
|
||||
required:
|
||||
- uuid
|
||||
- name
|
||||
- styledName
|
||||
# TODO [Stijn] [2026-07-19]: Add who they ignore and who they are ignored by
|
||||
|
||||
properties:
|
||||
uuid:
|
||||
@@ -140,11 +183,3 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Server"
|
||||
parameters:
|
||||
From:
|
||||
name: from
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user