Compare commits
114
Commits
5622db917b
...
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 | ||
|
|
819739ff60 | ||
|
|
5be3072b26 | ||
|
|
5987ee4133 | ||
|
|
6a52d27c09 | ||
|
|
73ea715b01 | ||
|
|
ed942d5ec5 | ||
|
|
a9d788b319 | ||
|
|
d1acf39a22 | ||
|
|
8f9aea2e0f | ||
|
|
8ecfb99b27 | ||
|
|
79a62e9ce9 | ||
|
|
8931bdc30d | ||
|
|
f9f05dcc88 | ||
|
|
0d6a4ee8dd | ||
|
|
3100ac21c3 | ||
|
|
3462d82716 | ||
|
|
b1267068a5 | ||
|
|
1c2478e8d0 | ||
|
|
7750bf6439 | ||
|
|
a390ef98e6 | ||
|
|
cd18fd4b96 | ||
|
|
9e5148fa8c | ||
|
|
564582a6f9 | ||
|
|
660fd4eb9c | ||
|
|
7138487912 | ||
|
|
ce6f59b543 | ||
|
|
915b64cdb0 | ||
|
|
b415d2968d | ||
|
|
a3cb1247af | ||
|
|
aca9bd45ab | ||
|
|
3f974b2dff | ||
|
|
51416aec01 | ||
|
|
a5c6ab9020 | ||
|
|
d61f2ed938 | ||
|
|
73132fae13 | ||
|
|
6996ecf48c | ||
|
|
634de9a856 | ||
|
|
7a360d1a26 | ||
|
|
0233958118 | ||
|
|
357948f134 | ||
|
|
4b6a491919 | ||
|
|
4708da0c43 | ||
|
|
aa0c61cf5e | ||
|
|
04ca05cd14 | ||
|
|
1eb0aac97e | ||
|
|
5b28f2b707 | ||
|
|
dde66bd608 | ||
|
|
646bbd4145 | ||
|
|
9ebd420874 | ||
|
|
321e736d4b | ||
|
|
837f87d9be | ||
|
|
536439f324 | ||
|
|
2a2525e205 | ||
|
|
9a47cb6652 | ||
|
|
8657a33e65 | ||
|
|
d4b09a500f | ||
|
|
d67f1a4f87 | ||
|
|
0113a38ace | ||
|
|
7d82beb1c7 | ||
|
|
138c01cf25 | ||
|
|
0e4ce21c58 | ||
|
|
44d4cc1f05 | ||
|
|
fe3e532476 | ||
|
|
b7fe37d9c4 | ||
|
|
984bbae7e3 | ||
|
|
58ea7d827e | ||
|
|
87a75639cf | ||
|
|
10e1f191b5 | ||
|
|
6cb43b412c | ||
|
|
af3ef7155a | ||
|
|
8a0843128c | ||
|
|
63aa7fd550 | ||
|
|
dc1b29e52c | ||
|
|
9b8c4891f4 | ||
|
|
b4fcbed781 | ||
|
|
dc1ed8ffee | ||
|
|
57fb00f685 | ||
|
|
08a42801df | ||
|
|
be78b66c5a | ||
|
|
37d7c37f3b | ||
|
|
d9b60d8a94 | ||
|
|
d1ff7b3f88 | ||
|
|
a6fbd19b6d | ||
|
|
525116e89b | ||
|
|
c56f5f9fe1 | ||
|
|
a9e9f1f03a | ||
|
|
beb5cd496a | ||
|
|
186a26fae1 | ||
|
|
9c0a298145 |
@@ -91,3 +91,6 @@ generated
|
||||
|
||||
liquibase*.properties
|
||||
node
|
||||
|
||||
.env
|
||||
virtual_view/*
|
||||
|
||||
@@ -43,6 +43,7 @@ dependencies {
|
||||
//Open API
|
||||
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
|
||||
implementation("io.swagger.core.v3:swagger-models:2.2.37")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
|
||||
//AOP
|
||||
implementation("org.aspectj:aspectjrt:1.9.19")
|
||||
|
||||
@@ -4,9 +4,11 @@ import com.alttd.altitudeweb.setup.Connection;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"})
|
||||
@EnableAspectJAutoProxy
|
||||
@EnableScheduling
|
||||
public class AltitudeWebApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
@@ -26,6 +27,7 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
@@ -42,23 +44,40 @@ public class SecurityConfig {
|
||||
private final KeyPairService keyPairService;
|
||||
private final SecurityAuthFailureHandler securityAuthFailureHandler;
|
||||
|
||||
@Value("${chat.allowed-ip}")
|
||||
private String allowedIp;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
WebExpressionAuthorizationManager allowedIpCheck = new WebExpressionAuthorizationManager(
|
||||
"hasIpAddress('%s')".formatted(allowedIp));
|
||||
return http
|
||||
.authorizeHttpRequests(
|
||||
auth -> auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/chat/send/**").access(allowedIpCheck)
|
||||
.requestMatchers("/actuator/mappings").access(allowedIpCheck)
|
||||
.requestMatchers("/api/form/**").authenticated()
|
||||
.requestMatchers("/api/login/getUsername").authenticated()
|
||||
.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())
|
||||
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
//TODO allow users access to their own folder
|
||||
.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)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
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) {
|
||||
List<ChatMessage> list = chatMessageDtoList.stream().map(ChatMessageMapper::fromDto).toList();
|
||||
chatService.addChatMessage(list);
|
||||
return new ResponseEntity<>(HttpStatus.ACCEPTED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> updateServerStates(ServerStateDto serverStateDto) {
|
||||
chatService.updateServerState(serverStateDto);
|
||||
return new ResponseEntity<>(HttpStatus.ACCEPTED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.alttd.altitudeweb.controllers.chat;
|
||||
|
||||
import com.alttd.altitudeweb.api.ChatInfoApi;
|
||||
import com.alttd.altitudeweb.model.PartyNameDto;
|
||||
import com.alttd.altitudeweb.model.PlayerNameDto;
|
||||
import com.alttd.altitudeweb.services.chat.ChatInfoService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class ChatInfoController implements ChatInfoApi {
|
||||
|
||||
private final ChatInfoService chatInfoService;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<PlayerNameDto> getName(UUID uuid) {
|
||||
return chatInfoService.getPlayerName(uuid)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<PartyNameDto> getPartyName(String id) {
|
||||
int partyId;
|
||||
try {
|
||||
partyId = Integer.parseInt(id);
|
||||
} catch (NumberFormatException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
return chatInfoService.getPartyName(partyId)
|
||||
.map(partyName -> {
|
||||
PartyNameDto partyNameDto = new PartyNameDto();
|
||||
partyNameDto.setName(partyName);
|
||||
return partyNameDto;
|
||||
})
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.alttd.altitudeweb.controllers.chat;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
public class ChatMessage {
|
||||
|
||||
private final UUID uuid;
|
||||
@Setter
|
||||
private Instant timestamp;
|
||||
private final String server;
|
||||
private final ChatMessageType type;
|
||||
private final String channel;
|
||||
private final String receiver;
|
||||
private final String messageJson;
|
||||
private final boolean notBlocked;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.alttd.altitudeweb.controllers.chat;
|
||||
|
||||
import com.alttd.altitudeweb.database.chat.ChatLogDao;
|
||||
import com.alttd.altitudeweb.model.ChatMessageDto;
|
||||
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;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@UtilityClass
|
||||
public class ChatMessageMapper {
|
||||
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 ChatMessage fromDto(ChatMessageDto dto) {
|
||||
return ChatMessage.builder()
|
||||
.uuid(dto.getUuid())
|
||||
.timestamp(dto.getTimestamp().toInstant())
|
||||
.server(dto.getServer())
|
||||
.type(fromDto(dto.getType()))
|
||||
.channel(dto.getChannel())
|
||||
.receiver(dto.getReceiver())
|
||||
.messageJson(dto.getMessage())
|
||||
.notBlocked(!dto.getBlocked())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ChatMessageType fromDto(ChatMessageDto.TypeEnum type) {
|
||||
return switch (type) {
|
||||
case PUBLIC -> ChatMessageType.PUBLIC;
|
||||
case GLOBAL -> ChatMessageType.GLOBAL;
|
||||
case PARTY -> ChatMessageType.PARTY;
|
||||
case GAC -> ChatMessageType.GAC;
|
||||
case MSG -> ChatMessageType.MSG;
|
||||
case CUSTOM -> ChatMessageType.CUSTOM;
|
||||
case UNKNOWN_DEFAULT_OPEN_API -> throw new IllegalArgumentException("Unknown chat message type");
|
||||
};
|
||||
}
|
||||
|
||||
public static ChatMessage fromDao(ChatLogDao dao) {
|
||||
return ChatMessage.builder()
|
||||
.uuid(UUID.fromString(dao.getUuid()))
|
||||
.timestamp(dao.getTimeStamp().toInstant())
|
||||
.server(dao.getServer())
|
||||
.type(fromString(dao.getType()))
|
||||
.channel(dao.getChannel())
|
||||
.receiver(dao.getReceiver())
|
||||
.messageJson(dao.getMiniMessage())
|
||||
.notBlocked(!dao.isBlocked())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ChatMessageType fromString(String type) {
|
||||
return switch (type) {
|
||||
case "public" -> ChatMessageType.PUBLIC;
|
||||
case "global" -> ChatMessageType.GLOBAL;
|
||||
case "party" -> ChatMessageType.PARTY;
|
||||
case "gac" -> ChatMessageType.GAC;
|
||||
case "msg" -> ChatMessageType.MSG;
|
||||
case "custom" -> ChatMessageType.CUSTOM;
|
||||
default -> throw new IllegalArgumentException("Unknown chat message type");
|
||||
};
|
||||
}
|
||||
|
||||
public static String toJson(ChatMessage chatMessage) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(chatMessage);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Failed to serialize ChatMessage to JSON: " + chatMessage.getUuid(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.alttd.altitudeweb.controllers.chat;
|
||||
|
||||
public enum ChatMessageType {
|
||||
PUBLIC,
|
||||
GLOBAL,
|
||||
PARTY,
|
||||
GAC,
|
||||
MSG,
|
||||
CUSTOM;
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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;
|
||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
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.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/chat/read")
|
||||
public class EventController {
|
||||
|
||||
private final static Duration MESSAGE_HISTORY_DURATION = Duration.ofHours(1);
|
||||
private final ChatService chatService;
|
||||
|
||||
@GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter subscribe(@AuthenticationPrincipal Jwt jwt,
|
||||
HttpServletResponse response) {
|
||||
if (jwt == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
List<String> authorities = jwt.getClaimAsStringList("authorities");
|
||||
if (authorities == null || doesNotHavePermission(authorities)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
response.setHeader("X-Accel-Buffering", "no"); // disables nginx buffering if present
|
||||
response.setHeader("Cache-Control", "no-cache");
|
||||
|
||||
UUID subject;
|
||||
try {
|
||||
subject = UUID.fromString(jwt.getSubject());
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid UUID in JWT subject: {}", jwt.getSubject());
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
String json = chatService.getMessagesSince(Instant.now().minus(MESSAGE_HISTORY_DURATION))
|
||||
.stream()
|
||||
.map(ChatMessageMapper::toJson)
|
||||
.collect(Collectors.joining(",", "[", "]"));
|
||||
|
||||
Integer partyId = getPartyId(subject);
|
||||
EventUser eventUser = new EventUser(subject, authorities, partyId);
|
||||
return chatService.subscribe(eventUser, json);
|
||||
}
|
||||
|
||||
private Integer getPartyId(UUID subject) {
|
||||
CompletableFuture<Integer> partyIdFuture = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.CHAT)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading party id for uuid {}", subject);
|
||||
try {
|
||||
Integer partyId = sqlSession.getMapper(ChatLogMapper.class)
|
||||
.getPartyId(subject.toString())
|
||||
.orElse(null);
|
||||
|
||||
partyIdFuture.complete(partyId);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load party id", e);
|
||||
partyIdFuture.complete(null);
|
||||
}
|
||||
});
|
||||
|
||||
return partyIdFuture.join();
|
||||
}
|
||||
|
||||
private static boolean doesNotHavePermission(List<String> authorities) {
|
||||
return !authorities.contains(PermissionClaimDto.HEAD_MOD.getValue()) && !authorities.contains(PermissionClaimDto.MOD.getValue());
|
||||
}
|
||||
}
|
||||
-1
@@ -12,7 +12,6 @@ import com.alttd.altitudeweb.model.PunishmentHistoryDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
+13
-48
@@ -24,34 +24,27 @@ import java.nio.file.Path;
|
||||
@RestController
|
||||
public class ParticleController implements ParticlesApi {
|
||||
|
||||
@Value("${login.secret:#{null}}")
|
||||
private String loginSecret;
|
||||
// @Value("${login.secret:#{null}}")
|
||||
// private String loginSecret;
|
||||
|
||||
@Value("${particles.file_path}")
|
||||
private String particlesFilePath;
|
||||
|
||||
@Value("${notification.server.url:http://localhost:8080}")
|
||||
private String notificationServerUrl;
|
||||
// @Value("${notification.server.url:http://localhost:8080}")
|
||||
// private String notificationServerUrl;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Resource> downloadFile(String authorization, String filename) throws Exception {
|
||||
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
public ResponseEntity<Resource> downloadFile(String filename) {
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
File targetFile = new File(file, filename);
|
||||
return getFileForDownload(targetFile, filename);
|
||||
return getFileForDownload(file, filename);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
|
||||
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
public ResponseEntity<Resource> downloadFileForUser(String uuid, String filename) {
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
|
||||
@@ -67,6 +60,7 @@ public class ParticleController implements ParticlesApi {
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
|
||||
filename += ".json";
|
||||
File targetFile = new File(file, filename);
|
||||
if (!targetFile.exists()) {
|
||||
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
|
||||
@@ -95,19 +89,17 @@ public class ParticleController implements ParticlesApi {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception {
|
||||
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) {
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||
notifyServerOfFileUpload(filename);
|
||||
return voidResponseEntity;
|
||||
return writeContentToFile(file, filename, content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
|
||||
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) {
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
|
||||
@@ -121,39 +113,12 @@ public class ParticleController implements ParticlesApi {
|
||||
}
|
||||
}
|
||||
|
||||
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||
notifyServerOfFileUpload(uuid, filename);
|
||||
return voidResponseEntity;
|
||||
}
|
||||
|
||||
private void notifyServerOfFileUpload(String filename) {
|
||||
String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
|
||||
sendNotification(notificationUrl, String.format("file upload: %s", filename));
|
||||
}
|
||||
|
||||
private void notifyServerOfFileUpload(String uuid, String filename) {
|
||||
String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
|
||||
sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
|
||||
}
|
||||
|
||||
private void sendNotification(String notificationUrl, String logDescription) {
|
||||
try {
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
log.info("Successfully notified server of {}", logDescription);
|
||||
} else {
|
||||
log.warn("Failed to notify server of {}, status: {}",
|
||||
logDescription, response.getStatusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error notifying server of {}", logDescription, e);
|
||||
}
|
||||
return writeContentToFile(file, filename, content);
|
||||
}
|
||||
|
||||
|
||||
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
|
||||
filename += ".json";
|
||||
File targetFile = new File(dir, filename);
|
||||
if (!Files.isWritable(targetFile.toPath())) {
|
||||
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.alttd.altitudeweb.services.chat;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.chat.NicknameMapper;
|
||||
import com.alttd.altitudeweb.database.chat.PartyMapper;
|
||||
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
|
||||
import com.alttd.altitudeweb.model.PlayerNameDto;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ChatInfoService {
|
||||
|
||||
public Optional<PlayerNameDto> getPlayerName(UUID uuid) {
|
||||
String username = Connection.getConnection(Databases.LUCK_PERMS)
|
||||
.runQueryWithResult(sqlSession -> sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString()));
|
||||
|
||||
if (username == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<String> nickname = Connection.getConnection(Databases.CHAT)
|
||||
.runQueryWithResult(sqlSession -> sqlSession.getMapper(NicknameMapper.class).getNicknameFromUUID(uuid.toString()));
|
||||
|
||||
PlayerNameDto playerNameDto = new PlayerNameDto();
|
||||
playerNameDto.setName(username);
|
||||
playerNameDto.setNickname(nickname.orElse(null));
|
||||
|
||||
return Optional.of(playerNameDto);
|
||||
}
|
||||
|
||||
public Optional<String> getPartyName(int id) {
|
||||
return Connection.getConnection(Databases.CHAT)
|
||||
.runQueryWithResult(sqlSession -> sqlSession.getMapper(PartyMapper.class).getPartyName(id));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
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 long lastMillis = 0;
|
||||
|
||||
@Scheduled(cron = "0 * * * * *")
|
||||
public void clearOldMessages() {
|
||||
Instant cutoff = Instant.now().minus(MAX_AGE);
|
||||
int currentSize = chatMessages.size();
|
||||
|
||||
chatMessages.headMap(cutoff, false).clear();
|
||||
|
||||
log.info("Clearing old chat messages. Removed {}/{} messages",
|
||||
currentSize - chatMessages.size(), currentSize);
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void loadMessagesFromDb() {
|
||||
Connection.getConnection(Databases.CHAT)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading uuid search result count");
|
||||
try {
|
||||
sqlSession.getMapper(ChatLogMapper.class)
|
||||
.getChatLogs(Instant.now().minus(Duration.ofHours(1)).toEpochMilli())
|
||||
.stream()
|
||||
.map(ChatMessageMapper::fromDao)
|
||||
.forEach(this::putChatMessage);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load history count", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized SseEmitter subscribe(EventUser eventUser, String json) {
|
||||
String key = eventUser.uuid().toString();
|
||||
boolean newUser = eventUserMap.put(key, eventUser) == null;
|
||||
SseEmitter emitter = eventPublisher.subscribe(key, json, this::handleSessionEnd);
|
||||
if (newUser) {
|
||||
sendPlayerStateToServers();
|
||||
}
|
||||
sendServerStateToUser(key, eventUser);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private synchronized void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
|
||||
EventUser eventUser = eventUserMap.get(key);
|
||||
if (eventUser == null) {
|
||||
log.error("Failed to find event user for key {}", key);
|
||||
return;
|
||||
}
|
||||
ChatSession chatSession = ChatSession.builder()
|
||||
.uuid(eventUser.uuid())
|
||||
.session_start(sessionStart)
|
||||
.session_end(sessionEnd)
|
||||
.build();
|
||||
saveSession(chatSession);
|
||||
|
||||
if (!eventPublisher.hasSubscribers(key) && eventUserMap.remove(key, eventUser)) {
|
||||
hiddenUsers.remove(eventUser.uuid());
|
||||
sendPlayerStateToServers();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void hideUserFromPlayerList(UUID uuid) {
|
||||
requireActiveHeadMod(uuid);
|
||||
if (hiddenUsers.add(uuid)) {
|
||||
sendPlayerStateToServers();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void showUserInPlayerList(UUID uuid) {
|
||||
requireActiveHeadMod(uuid);
|
||||
if (hiddenUsers.remove(uuid)) {
|
||||
sendPlayerStateToServers();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean isUserHiddenFromPlayerList(UUID uuid) {
|
||||
requireActiveHeadMod(uuid);
|
||||
return hiddenUsers.contains(uuid);
|
||||
}
|
||||
|
||||
private void requireActiveHeadMod(UUID uuid) {
|
||||
EventUser eventUser = eventUserMap.get(uuid.toString());
|
||||
if (eventUser == null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "No active chat session");
|
||||
}
|
||||
if (!eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only head moderators can change session visibility");
|
||||
}
|
||||
}
|
||||
|
||||
private void sendPlayerStateToServers() {
|
||||
ArrayList<UUID> activePlayers = eventUserMap.values().stream()
|
||||
.map(EventUser::uuid)
|
||||
.filter(uuid -> !hiddenUsers.contains(uuid))
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
serverMessageService.sendMessageToAll("player_state", PlayerListStateMapper.fromList(activePlayers));
|
||||
}
|
||||
|
||||
private void saveSession(ChatSession chatSession) {
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Saving chat session");
|
||||
try {
|
||||
sqlSession.getMapper(ChatSessionMapper.class).storeSession(chatSession);
|
||||
log.debug("Saved chat session");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to save chat session", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private synchronized void setUniqueTimestamp(ChatMessage chatMessage) {
|
||||
long timestamp = chatMessage.getTimestamp().toEpochMilli();
|
||||
if (timestamp == lastMillis) {
|
||||
timestamp += 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
lastMillis = timestamp;
|
||||
chatMessage.setTimestamp(Instant.ofEpochMilli(timestamp));
|
||||
}
|
||||
|
||||
private void putChatMessage(ChatMessage chatMessage) {
|
||||
setUniqueTimestamp(chatMessage);
|
||||
chatMessages.put(chatMessage.getTimestamp(), chatMessage);
|
||||
}
|
||||
|
||||
public void addChatMessage(List<ChatMessage> chatMessageList) {
|
||||
chatMessageList.forEach(this::putChatMessage);
|
||||
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));
|
||||
}
|
||||
|
||||
sendToUsers("chat", (eventUser) ->
|
||||
chatMessageList.stream()
|
||||
.filter(chatMessage -> shouldReceive(eventUser, chatMessage))
|
||||
.map(jsonCache::get)
|
||||
.collect(Collectors.joining(",", "[", "]"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Broadcasts to every currently subscribed key. The message content for
|
||||
* each key is resolved via messageForUser, which now works off the
|
||||
* unique key itself rather than an EventUser instance.
|
||||
*/
|
||||
public void sendToUsers(String eventName, MessageForUser messageForUser) {
|
||||
eventUserMap.forEach((key, eventUser) -> {
|
||||
String json = messageForUser.get(eventUser);
|
||||
eventPublisher.sendToUser(key, eventName, json);
|
||||
});
|
||||
}
|
||||
|
||||
private boolean shouldReceive(EventUser eventUser, ChatMessage chatMessage) {
|
||||
if (eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Arrays.asList(allowedServers).contains(chatMessage.getServer())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!chatMessage.isNotBlocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return switch (chatMessage.getType()) {
|
||||
case PUBLIC, GLOBAL -> true;
|
||||
case PARTY -> {
|
||||
Integer partyId = eventUser.partyId();
|
||||
if (partyId == null) {
|
||||
yield false;
|
||||
}
|
||||
yield chatMessage.getChannel().equals(String.valueOf(partyId));
|
||||
}
|
||||
case MSG -> eventUser.uuid().toString().equals(chatMessage.getReceiver());
|
||||
case GAC -> eventUser.hasPermission(PermissionClaimDto.MOD);
|
||||
case CUSTOM -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public List<ChatMessage> getMessagesSince(Instant instant) {
|
||||
return chatMessages.tailMap(instant, false).values().stream().toList();
|
||||
}
|
||||
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package com.alttd.altitudeweb.services.chat.event_publisher;
|
||||
|
||||
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.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Generic SSE event publisher, keyed by an arbitrary "unique" string
|
||||
* 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<String, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
|
||||
private final Map<SseEmitter, Runnable> emitterCleanupMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Invoked once a given emitter for uniqueKey is cleaned up (completed,
|
||||
* 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(uniqueKey, k -> new CopyOnWriteArrayList<>()).add(emitter);
|
||||
Instant sessionStart = Instant.now();
|
||||
log.info("Key {} subscribed, active emitters: {}", uniqueKey, countActive());
|
||||
|
||||
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);
|
||||
emitter.onTimeout(cleanup);
|
||||
emitter.onError(e -> cleanup.run());
|
||||
|
||||
try {
|
||||
if (initialData != null) {
|
||||
emitter.send(SseEmitter.event().name("connected").data(initialData));
|
||||
} else {
|
||||
emitter.send(SseEmitter.event().name("connected"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
cleanup.run();
|
||||
}
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
// runs every 20s: keeps idle timeouts from firing,
|
||||
// AND prunes emitters whose sockets are actually dead
|
||||
@Scheduled(fixedRate = 20_000)
|
||||
public void heartbeat() {
|
||||
emitterMap.values().forEach(list -> {
|
||||
for (SseEmitter emitter : list) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().comment("heartbeat"));
|
||||
} catch (IOException e) {
|
||||
cleanup(emitter);
|
||||
}
|
||||
}
|
||||
});
|
||||
emitterMap.entrySet().removeIf(e -> e.getValue().isEmpty());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
for (SseEmitter emitter : userEmitters) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(eventName).data(json));
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to send event to user: {}", uniqueKey, e);
|
||||
cleanup(emitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private void cleanup(SseEmitter emitter) {
|
||||
Runnable cleanup = emitterCleanupMap.get(emitter);
|
||||
if (cleanup != null) {
|
||||
cleanup.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCleanup(String uniqueKey, SseEmitter emitter, Instant sessionStart,
|
||||
SessionCleanupCallback cleanupCallback) {
|
||||
emitterCleanupMap.remove(emitter);
|
||||
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
|
||||
if (userEmitters != null) {
|
||||
userEmitters.remove(emitter);
|
||||
if (userEmitters.isEmpty()) {
|
||||
emitterMap.remove(uniqueKey);
|
||||
}
|
||||
}
|
||||
Instant sessionEnd = Instant.now();
|
||||
Duration between = Duration.between(sessionStart, sessionEnd);
|
||||
log.info("Cleaned up emitter for {}, active emitters: {}, session duration: {}",
|
||||
uniqueKey, countActive(), between
|
||||
);
|
||||
|
||||
if (cleanupCallback != null) {
|
||||
cleanupCallback.onCleanup(uniqueKey, sessionStart, sessionEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.alttd.altitudeweb.services.chat.event_publisher;
|
||||
|
||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public record EventUser(UUID uuid, List<String> authorities, Integer partyId) {
|
||||
|
||||
public boolean hasPermission(PermissionClaimDto permission) {
|
||||
return authorities.contains(permission.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
EventUser eventUser = (EventUser) o;
|
||||
return Objects.equals(uuid, eventUser.uuid);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(uuid);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.alttd.altitudeweb.services.chat.event_publisher;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface MessageForUser {
|
||||
|
||||
String get(EventUser eventUser);
|
||||
|
||||
}
|
||||
+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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,10 +23,7 @@ import org.springframework.stereotype.Service;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
@@ -50,7 +47,7 @@ public class AppealDiscord {
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"User",
|
||||
"""
|
||||
Discord Username: %s
|
||||
Discord Username: `%s`
|
||||
Discord id: %s
|
||||
MC UUID: %s
|
||||
Submitted: %s
|
||||
@@ -121,7 +118,7 @@ public class AppealDiscord {
|
||||
// Group: User
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"User",
|
||||
"Username: " + safe(appeal.username()) + "\n" +
|
||||
"Username: `" + safe(appeal.username()) + "`\n" +
|
||||
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
|
||||
"Submitted: " + createdAt,
|
||||
false
|
||||
@@ -258,9 +255,10 @@ public class AppealDiscord {
|
||||
Optional<AppealList> optionalNextAppealList = appealList
|
||||
.stream()
|
||||
.filter(entry -> entry.userId() > assignTo.userId())
|
||||
.sorted()
|
||||
.findFirst();
|
||||
AppealList nextAppealList = optionalNextAppealList.orElse(appealList.stream().sorted().findFirst().orElse(assignTo));
|
||||
.min(Comparator.comparing(AppealList::userId));
|
||||
AppealList nextAppealList = optionalNextAppealList.orElse(appealList.stream()
|
||||
.min(Comparator.comparing(AppealList::userId))
|
||||
.orElse(assignTo));
|
||||
mapper.updateNext(assignTo.userId(), false);
|
||||
mapper.updateNext(nextAppealList.userId(), true);
|
||||
} catch (Exception e) {
|
||||
|
||||
+2
-2
@@ -47,8 +47,8 @@ public class StaffApplicationDiscord {
|
||||
List<DiscordSender.EmbedField> fields = new ArrayList<>();
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Applicant",
|
||||
"Username: " + safe(username) + "\n" +
|
||||
"Discord: " + safe(application.discordUsername()) + "\n" +
|
||||
"Username: `" + safe(username) + "`\n" +
|
||||
"Discord: `" + safe(application.discordUsername()) + "`\n" +
|
||||
"Email: " + safe(application.email()) + "\n" +
|
||||
"Age: " + safe(String.valueOf(application.age())) + "\n" +
|
||||
"Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()),
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.alttd.altitudeweb.services.forms;
|
||||
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
||||
@@ -56,11 +57,11 @@ public class DiscordAppeal {
|
||||
}
|
||||
BannedUser bannedUser = join.get();
|
||||
|
||||
Optional<UUID> uuid = authenticatedUuid.tryGetAuthenticatedUserUuid();
|
||||
if (uuid.isEmpty()) {
|
||||
Optional<UUID> optionalUUID = authenticatedUuid.tryGetAuthenticatedUserUuid();
|
||||
if (optionalUUID.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated");
|
||||
}
|
||||
|
||||
UUID uuid = optionalUUID.get();
|
||||
CompletableFuture<com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal> appealCompletableFuture = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
@@ -68,7 +69,7 @@ public class DiscordAppeal {
|
||||
log.debug("Loading history by id");
|
||||
try {
|
||||
com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal discordAppealRecord = discordAppealDtoToDiscordAppealMapper
|
||||
.map(discordAppealDto, uuid.get(), bannedUser.name());
|
||||
.map(discordAppealDto, uuid, bannedUser.name());
|
||||
sqlSession.getMapper(DiscordAppealMapper.class).createDiscordAppeal(discordAppealRecord);
|
||||
appealCompletableFuture.complete(discordAppealRecord);
|
||||
} catch (Exception e) {
|
||||
@@ -104,7 +105,8 @@ public class DiscordAppeal {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to send appeal to Discord");
|
||||
}
|
||||
//TODO verify mail
|
||||
appealMail.sendAppealNotification(discordAppeal);
|
||||
String username = getUsername(uuid);
|
||||
appealMail.sendAppealNotification(discordAppeal, username);
|
||||
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
@@ -117,4 +119,20 @@ public class DiscordAppeal {
|
||||
"Your appeal has been submitted. You will be notified when it has been reviewed.",
|
||||
true);
|
||||
}
|
||||
|
||||
private String getUsername(UUID uuid) {
|
||||
CompletableFuture<String> usernameFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.LUCK_PERMS)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading username for uuid {}", uuid);
|
||||
try {
|
||||
String username = sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString());
|
||||
usernameFuture.complete(username);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load username for uuid {}", uuid, e);
|
||||
usernameFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return usernameFuture.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ public class AppealMail {
|
||||
*
|
||||
* @param appeal The appeal object containing all necessary information
|
||||
*/
|
||||
public void sendAppealNotification(DiscordAppeal appeal) {
|
||||
public void sendAppealNotification(DiscordAppeal appeal, String username) {
|
||||
try {
|
||||
sendEmailToAppealsTeam(appeal);
|
||||
sendEmailToAppealsTeam(appeal, username);
|
||||
|
||||
log.info("Discord Appeal notification emails sent successfully for appeal ID: {}", appeal.id());
|
||||
} catch (Exception e) {
|
||||
@@ -81,7 +81,7 @@ public class AppealMail {
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
private void sendEmailToAppealsTeam(DiscordAppeal appeal) throws MessagingException {
|
||||
private void sendEmailToAppealsTeam(DiscordAppeal appeal, String username) throws MessagingException {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = getAppealMimeMessageHelper(appeal, message);
|
||||
|
||||
@@ -90,6 +90,8 @@ public class AppealMail {
|
||||
context.setVariable("createdAt", appeal.createdAt()
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")));
|
||||
|
||||
context.setVariable("minecraftName", username);
|
||||
String content = templateEngine.process("discord-appeal-email", context);
|
||||
|
||||
helper.setText(content, true);
|
||||
|
||||
@@ -6,7 +6,7 @@ database.user=${DB_USER:root}
|
||||
database.password=${DB_PASSWORD:root}
|
||||
cors.allowed-origins=${CORS:https://alttd.com}
|
||||
login.secret=${LOGIN_SECRET:SET_TOKEN}
|
||||
particles.file_path=${user.home}/.altitudeweb/particles
|
||||
particles.file_path=${PARTICLE_FILE_PATH:${user.home}/.altitudeweb/particles}
|
||||
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
|
||||
my-server.address=${SERVER_ADDRESS:https://alttd.com}
|
||||
logging.level.com.alttd.altitudeweb=INFO
|
||||
@@ -18,3 +18,10 @@ spring.mail.password=${MAIL_PASSWORD}
|
||||
spring.mail.properties.mail.smtp.auth=true
|
||||
spring.mail.properties.mail.smtp.ssl.enable=true
|
||||
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
|
||||
chat.allowed-ip=${ALLOWED_IP:10.0.0.0/24}
|
||||
chat.allowed-servers=${ALLOWED_SERVERS:bayou,lobby,creative}
|
||||
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}
|
||||
|
||||
@@ -102,8 +102,9 @@
|
||||
<div>
|
||||
<h2>User information</h2>
|
||||
<ul>
|
||||
<li><strong>Username:</strong> <span th:text="${appeal.discordUsername}">username</span></li>
|
||||
<li><strong>Discord Username:</strong> <span th:text="${appeal.discordUsername}">dc username</span></li>
|
||||
<li><strong>UUID:</strong> <span th:text="${appeal.uuid}">uuid</span></li>
|
||||
<li><strong>Minecraft Username:</strong> <span th:text="${minecraftName}">mc username</span></li>
|
||||
<li><strong>Email:</strong> <span th:text="${appeal.email}">email</span></li>
|
||||
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.alttd.altitudeweb.controllers.chat;
|
||||
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.model.ServerStateDto;
|
||||
import com.alttd.altitudeweb.model.PlayerListVisibilityDto;
|
||||
import com.alttd.altitudeweb.services.chat.ChatService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ChatControllerTest {
|
||||
|
||||
@Mock
|
||||
private ChatService chatService;
|
||||
|
||||
@Mock
|
||||
private AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@InjectMocks
|
||||
private ChatController chatController;
|
||||
|
||||
@Test
|
||||
void testUpdateServerStatesDelegatesToService() {
|
||||
ServerStateDto serverStateDto = new ServerStateDto();
|
||||
|
||||
ResponseEntity<Void> response = chatController.updateServerStates(serverStateDto);
|
||||
|
||||
verify(chatService).updateServerState(serverStateDto);
|
||||
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hideUserFromPlayerListDelegatesForAuthenticatedUser() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
|
||||
|
||||
ResponseEntity<Void> response = chatController.hideUserFromPlayerList();
|
||||
|
||||
verify(chatService).hideUserFromPlayerList(uuid);
|
||||
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void showUserInPlayerListDelegatesForAuthenticatedUser() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
|
||||
|
||||
ResponseEntity<Void> response = chatController.showUserInPlayerList();
|
||||
|
||||
verify(chatService).showUserInPlayerList(uuid);
|
||||
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPlayerListVisibilityReturnsBackendState() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
|
||||
org.mockito.Mockito.when(chatService.isUserHiddenFromPlayerList(uuid)).thenReturn(true);
|
||||
|
||||
ResponseEntity<PlayerListVisibilityDto> response = chatController.getPlayerListVisibility();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(true, response.getBody().getHidden());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.alttd.altitudeweb.services.chat;
|
||||
|
||||
import com.alttd.altitudeweb.controllers.chat.ChatMessage;
|
||||
import com.alttd.altitudeweb.controllers.chat.ChatMessageType;
|
||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
||||
import com.alttd.altitudeweb.model.ServerDto;
|
||||
import com.alttd.altitudeweb.model.ServerStateDto;
|
||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
||||
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
|
||||
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
|
||||
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ChatServiceTest {
|
||||
|
||||
private ChatService chatService;
|
||||
private EventPublisher eventPublisher;
|
||||
private ServerMessageService serverMessageService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
eventPublisher = mock(EventPublisher.class);
|
||||
serverMessageService = mock(ServerMessageService.class);
|
||||
chatService = spy(new ChatService(eventPublisher, serverMessageService));
|
||||
ReflectionTestUtils.setField(chatService, "allowedServers", new String[]{"server1"});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublicMessageSentToEveryone() {
|
||||
ChatMessage message = ChatMessage.builder()
|
||||
.uuid(UUID.randomUUID())
|
||||
.type(ChatMessageType.PUBLIC)
|
||||
.server("server1")
|
||||
.messageJson("hello")
|
||||
.notBlocked(true)
|
||||
.timestamp(Instant.now())
|
||||
.build();
|
||||
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
|
||||
String result = messageForUser.get(user);
|
||||
|
||||
assertTrue(result.contains("hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGacMessageSentOnlyToStaff() {
|
||||
ChatMessage message = ChatMessage.builder()
|
||||
.uuid(UUID.randomUUID())
|
||||
.type(ChatMessageType.GAC)
|
||||
.server("server1")
|
||||
.messageJson("staff chat")
|
||||
.notBlocked(true)
|
||||
.timestamp(Instant.now())
|
||||
.build();
|
||||
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
|
||||
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
|
||||
assertFalse(messageForUser.get(regularUser).contains("staff chat"), "Regular user should not see GAC message");
|
||||
|
||||
EventUser modUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.MOD.getValue()), null);
|
||||
assertTrue(messageForUser.get(modUser).contains("staff chat"), "MOD should see GAC message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMsgMessageSentToSenderAndReceiver() {
|
||||
UUID senderUuid = UUID.randomUUID();
|
||||
UUID receiverUuid = UUID.randomUUID();
|
||||
ChatMessage message = ChatMessage.builder()
|
||||
.uuid(senderUuid)
|
||||
.type(ChatMessageType.MSG)
|
||||
.server("server1")
|
||||
.receiver(receiverUuid.toString())
|
||||
.messageJson("private message")
|
||||
.notBlocked(true)
|
||||
.timestamp(Instant.now())
|
||||
.build();
|
||||
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
|
||||
EventUser sender = new EventUser(senderUuid, List.of(), null);
|
||||
EventUser receiver = new EventUser(receiverUuid, List.of(), null);
|
||||
EventUser other = new EventUser(UUID.randomUUID(), List.of(), null);
|
||||
|
||||
assertTrue(messageForUser.get(receiver).contains("private message"), "Receiver should see the message");
|
||||
assertFalse(messageForUser.get(other).contains("private message"), "Others should not see private message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGlobalMessageSentToEveryone() {
|
||||
ChatMessage message = ChatMessage.builder()
|
||||
.uuid(UUID.randomUUID())
|
||||
.type(ChatMessageType.GLOBAL)
|
||||
.server("server1")
|
||||
.messageJson("global hello")
|
||||
.notBlocked(true)
|
||||
.timestamp(Instant.now())
|
||||
.build();
|
||||
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
|
||||
assertTrue(messageForUser.get(user).contains("global hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBlockedMessageOnlySentToHeadMod() {
|
||||
ChatMessage message = ChatMessage.builder()
|
||||
.uuid(UUID.randomUUID())
|
||||
.type(ChatMessageType.PUBLIC)
|
||||
.server("server1")
|
||||
.messageJson("blocked message")
|
||||
.notBlocked(false)
|
||||
.timestamp(Instant.now())
|
||||
.build();
|
||||
|
||||
chatService.addChatMessage(List.of(message));
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
|
||||
EventUser headModUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
|
||||
|
||||
assertFalse(messageForUser.get(regularUser).contains("blocked message"));
|
||||
assertTrue(messageForUser.get(headModUser).contains("blocked message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateServerStateFiltering() {
|
||||
ServerStateDto serverStateDto = new ServerStateDto()
|
||||
.addServersItem(new ServerDto().name("server1"))
|
||||
.addServersItem(new ServerDto().name("server2"));
|
||||
|
||||
chatService.updateServerState(serverStateDto);
|
||||
|
||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
||||
verify(chatService).sendToUsers(eq("server-state"), captor.capture());
|
||||
|
||||
MessageForUser messageForUser = captor.getValue();
|
||||
|
||||
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
|
||||
String regularResult = messageForUser.get(regularUser);
|
||||
assertTrue(regularResult.contains("server1"), "Regular user should see allowed server");
|
||||
assertFalse(regularResult.contains("server2"), "Regular user should NOT see non-allowed server");
|
||||
|
||||
EventUser headModUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
|
||||
String headModResult = messageForUser.get(headModUser);
|
||||
assertTrue(headModResult.contains("server1"), "HEAD_MOD should see allowed server");
|
||||
assertTrue(headModResult.contains("server2"), "HEAD_MOD should see non-allowed server");
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribingUserBroadcastsActivePlayerStateOnce() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
EventUser eventUser = new EventUser(uuid, List.of(), null);
|
||||
|
||||
chatService.subscribe(eventUser, "[]");
|
||||
chatService.subscribe(eventUser, "[]");
|
||||
|
||||
ArgumentCaptor<PlayerListState> stateCaptor = ArgumentCaptor.forClass(PlayerListState.class);
|
||||
verify(serverMessageService, times(1)).sendMessageToAll(eq("player_state"), stateCaptor.capture());
|
||||
assertEquals(List.of(uuid), stateCaptor.getValue().getActivePlayers());
|
||||
}
|
||||
|
||||
@Test
|
||||
void headModCanHideCurrentSessionFromPlayerState() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
EventUser eventUser = new EventUser(uuid, List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
|
||||
chatService.subscribe(eventUser, "[]");
|
||||
clearInvocations(serverMessageService);
|
||||
|
||||
chatService.hideUserFromPlayerList(uuid);
|
||||
|
||||
ArgumentCaptor<PlayerListState> stateCaptor = ArgumentCaptor.forClass(PlayerListState.class);
|
||||
verify(serverMessageService).sendMessageToAll(eq("player_state"), stateCaptor.capture());
|
||||
assertTrue(stateCaptor.getValue().getActivePlayers().isEmpty());
|
||||
assertTrue(chatService.isUserHiddenFromPlayerList(uuid));
|
||||
|
||||
clearInvocations(serverMessageService);
|
||||
chatService.showUserInPlayerList(uuid);
|
||||
|
||||
verify(serverMessageService).sendMessageToAll(eq("player_state"), stateCaptor.capture());
|
||||
assertEquals(List.of(uuid), stateCaptor.getValue().getActivePlayers());
|
||||
assertFalse(chatService.isUserHiddenFromPlayerList(uuid));
|
||||
}
|
||||
|
||||
@Test
|
||||
void modCannotHideCurrentSessionFromPlayerState() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
EventUser eventUser = new EventUser(uuid, List.of(PermissionClaimDto.MOD.getValue()), null);
|
||||
chatService.subscribe(eventUser, "[]");
|
||||
clearInvocations(serverMessageService);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> chatService.hideUserFromPlayerList(uuid));
|
||||
|
||||
assertEquals(403, exception.getStatusCode().value());
|
||||
verifyNoInteractions(serverMessageService);
|
||||
}
|
||||
}
|
||||
+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\":[]}");
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ public enum Databases {
|
||||
LITE_BANS("litebans"),
|
||||
DISCORD("discordLink"),
|
||||
PROXY_PLAYTIME("proxyplaytime"),
|
||||
VOTING_PLUGIN("votingplugin");
|
||||
VOTING_PLUGIN("votingplugin"),
|
||||
CHAT("chat");
|
||||
|
||||
private final String internalName;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.alttd.altitudeweb.database.chat;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
public class ChatLogDao {
|
||||
|
||||
private final String uuid;
|
||||
private final Timestamp timeStamp;
|
||||
private final String server;
|
||||
private final String type;
|
||||
private final String channel;
|
||||
private final String receiver;
|
||||
private final String miniMessage;
|
||||
private final boolean blocked;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.alttd.altitudeweb.database.chat;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ChatLogMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT uuid, time_stamp, server, type, channel, receiver, mini_message, blocked
|
||||
FROM chat_log
|
||||
WHERE mini_message IS NOT NULL
|
||||
AND time_stamp >= #{since}
|
||||
""")
|
||||
List<ChatLogDao> getChatLogs(@Param("since") long since);
|
||||
|
||||
@Select("""
|
||||
SELECT party_id FROM chat_users
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
Optional<Integer> getPartyId(@Param("uuid") String uuid);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.alttd.altitudeweb.database.chat;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface NicknameMapper {
|
||||
@Select("""
|
||||
SELECT nickname
|
||||
FROM nicknames
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
Optional<String> getNicknameFromUUID(@Param("uuid") String uuid);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.alttd.altitudeweb.database.chat;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PartyMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT party_name FROM parties
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
Optional<String> getPartyName(@Param("id") int id);
|
||||
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.alttd.altitudeweb.database.web_db.chat_session;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class ChatSession {
|
||||
|
||||
private final UUID uuid;
|
||||
private final Instant session_start;
|
||||
private final Instant session_end;
|
||||
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.alttd.altitudeweb.database.web_db.chat_session;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
|
||||
public interface ChatSessionMapper {
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO chat_session (uuid, session_start, session_end)
|
||||
VALUES (#{uuid}, #{session_start}, #{session_end})
|
||||
""")
|
||||
void storeSession(ChatSession chatSession);
|
||||
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
package com.alttd.altitudeweb.database.web_db.forms;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@@ -33,11 +34,11 @@ public interface AppealMapper {
|
||||
UPDATE appeals SET send_at = NOW()
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void markAppealAsSent(UUID id);
|
||||
void markAppealAsSent(@Param("id") UUID id);
|
||||
|
||||
@Update("""
|
||||
UPDATE appeals SET assigned_to = #{assignedTo}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void assignAppeal(UUID id, Long assignedTo);
|
||||
void assignAppeal(@Param("id") UUID id, @Param("assignedTo") Long assignedTo);
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,10 +1,9 @@
|
||||
package com.alttd.altitudeweb.database.web_db.forms;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface DiscordAppealMapper {
|
||||
@@ -19,11 +18,11 @@ public interface DiscordAppealMapper {
|
||||
UPDATE discord_appeals SET send_at = NOW()
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void markDiscordAppealAsSent(UUID id);
|
||||
void markDiscordAppealAsSent(@Param("id") UUID id);
|
||||
|
||||
@Update("""
|
||||
UPDATE discord_appeals SET assigned_to = #{assignedTo}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void assignDiscordAppeal(UUID id, Long assignedTo);
|
||||
void assignDiscordAppeal(@Param("id") UUID id, @Param("assignedTo") Long assignedTo);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ public class Connection {
|
||||
InitializeProxyPlaytime.init();
|
||||
InitializeDiscord.init();
|
||||
InitializeVotingPlugin.init();
|
||||
InitializeChat.init();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
@@ -143,6 +144,26 @@ public class Connection {
|
||||
}).start();
|
||||
}
|
||||
|
||||
public <T> T runQueryWithResult(QueryFunction<T> function) {
|
||||
if (sqlSessionFactory == null) {
|
||||
sqlSessionFactory = createSqlSessionFactory(settings, addMappers);
|
||||
}
|
||||
|
||||
try (SqlSession session = sqlSessionFactory.openSession()) {
|
||||
T result = function.apply(session);
|
||||
session.commit();
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to run query", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface QueryFunction<T> {
|
||||
T apply(SqlSession session);
|
||||
}
|
||||
|
||||
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
|
||||
try {
|
||||
Configuration configuration = getConfiguration(settings);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.alttd.altitudeweb.setup;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.chat.ChatLogMapper;
|
||||
import com.alttd.altitudeweb.database.chat.NicknameMapper;
|
||||
import com.alttd.altitudeweb.database.chat.PartyMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class InitializeChat {
|
||||
|
||||
protected static void init() {
|
||||
log.info("Initializing Chat");
|
||||
Connection.getConnection(Databases.CHAT, (configuration) -> {
|
||||
configuration.addMapper(ChatLogMapper.class);
|
||||
configuration.addMapper(NicknameMapper.class);
|
||||
configuration.addMapper(PartyMapper.class);
|
||||
}).join();
|
||||
log.debug("Initialized Chat");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
|
||||
@@ -28,6 +29,7 @@ public class InitializeWebDb {
|
||||
configuration.addMapper(DiscordAppealMapper.class);
|
||||
configuration.addMapper(StaffApplicationMapper.class);
|
||||
configuration.addMapper(EmailVerificationMapper.class);
|
||||
configuration.addMapper(ChatSessionMapper.class);
|
||||
}).join()
|
||||
.runQuery(sqlSession -> {
|
||||
createSettingsTable(sqlSession);
|
||||
@@ -38,6 +40,7 @@ public class InitializeWebDb {
|
||||
createdDiscordAppealTable(sqlSession);
|
||||
createStaffApplicationsTable(sqlSession);
|
||||
createUserEmailsTable(sqlSession);
|
||||
createChatSessionTable(sqlSession);
|
||||
});
|
||||
log.debug("Initialized WebDb");
|
||||
}
|
||||
@@ -208,4 +211,21 @@ public class InitializeWebDb {
|
||||
}
|
||||
}
|
||||
|
||||
private static void createChatSessionTable(@NotNull SqlSession sqlSession) {
|
||||
String query = """
|
||||
CREATE TABLE IF NOT EXISTS chat_session (
|
||||
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid UUID NOT NULL,
|
||||
session_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
session_end TIMESTAMP NULL,
|
||||
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
""";
|
||||
try (Statement statement = sqlSession.getConnection().createStatement()) {
|
||||
statement.execute(query);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+15
-13
@@ -13,17 +13,18 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^20.1.3",
|
||||
"@angular/common": "^20.1.0",
|
||||
"@angular/compiler": "^20.1.0",
|
||||
"@angular/core": "^20.1.0",
|
||||
"@angular/forms": "^20.1.0",
|
||||
"@angular/material": "^20.1.3",
|
||||
"@angular/platform-browser": "^20.1.0",
|
||||
"@angular/platform-browser-dynamic": "^20.1.0",
|
||||
"@angular/router": "^20.1.0",
|
||||
"@angular/cdk": "20.2.2",
|
||||
"@angular/common": "20.2.2",
|
||||
"@angular/compiler": "20.2.2",
|
||||
"@angular/core": "20.2.2",
|
||||
"@angular/forms": "20.2.2",
|
||||
"@angular/material": "20.2.2",
|
||||
"@angular/platform-browser": "20.2.2",
|
||||
"@angular/platform-browser-dynamic": "20.2.2",
|
||||
"@angular/router": "20.2.2",
|
||||
"@auth0/angular-jwt": "^5.2.0",
|
||||
"@types/three": "^0.177.0",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"ngx-cookie-service": "^20.0.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"three": "^0.177.0",
|
||||
@@ -31,9 +32,10 @@
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^20.1.0",
|
||||
"@angular/cli": "^20.1.0",
|
||||
"@angular/compiler-cli": "^20.1.0",
|
||||
"@angular/build": "20.2.2",
|
||||
"@angular/cli": "20.2.2",
|
||||
"@angular/compiler-cli": "20.2.2",
|
||||
"@types/event-source-polyfill": "^1.0.5",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
@@ -41,6 +43,6 @@
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8080",
|
||||
"target": "http://10.0.0.121:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,29 +0,0 @@
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {AppComponent} from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have the 'frontend' title`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('frontend');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {Meta, Title} from '@angular/platform-browser';
|
||||
import {ALTITUDE_VERSION} from '@custom-types/constant';
|
||||
import {Router, RouterOutlet} from '@angular/router';
|
||||
import {RouterOutlet} from '@angular/router';
|
||||
import {FooterComponent} from '@pages/footer/footer/footer.component';
|
||||
|
||||
@Component({
|
||||
@@ -27,9 +27,8 @@ export class AppComponent implements OnInit {
|
||||
ALTITUDE_VERSION + ',altitude,alttd,play,join,find,friends,friendly,simple,private,whitelist,whitelisted,creative,' +
|
||||
'worldedit'
|
||||
|
||||
constructor(private titleService: Title, private metaService: Meta, private router: Router) {
|
||||
|
||||
}
|
||||
private readonly titleService = inject(Title);
|
||||
private readonly metaService = inject(Meta);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.titleService.setTitle(this.title)
|
||||
|
||||
@@ -198,6 +198,10 @@ export const routes: Routes = [
|
||||
path: 'community',
|
||||
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
|
||||
},
|
||||
{
|
||||
path: 'chat',
|
||||
loadComponent: () => import('./pages/altitude/chat/chat.component').then(m => m.ChatComponent)
|
||||
},
|
||||
{
|
||||
path: 'nicknames',
|
||||
loadComponent: () => import('./pages/reference/nicknames/nicknames.component').then(m => m.NicknamesComponent)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<ng-container>
|
||||
<app-header [current_page]="'chat'" height="200px" background_image="/public/img/backgrounds/staff.png"
|
||||
[overlay_gradient]="0.5">
|
||||
<div class="title" header-content>
|
||||
<h1>Chat</h1>
|
||||
</div>
|
||||
</app-header>
|
||||
|
||||
@if (showFullscreenPrompt()) {
|
||||
<div class="fullscreen-prompt-backdrop">
|
||||
<div class="fullscreen-prompt">
|
||||
<h2>Enter fullscreen?</h2>
|
||||
<p>Would you like to view chat in fullscreen mode?</p>
|
||||
|
||||
<div class="fullscreen-prompt-actions">
|
||||
<button type="button" class="secondary" (click)="skipFullscreen()">No thanks</button>
|
||||
<button type="button" (click)="enterFullscreen()">Go fullscreen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<main>
|
||||
<app-full-size [hideFooter]="true">
|
||||
<section class="darkmodeSection full-height">
|
||||
<div class="page-layout">
|
||||
@if (!isMobile() || !userListVisible()) {
|
||||
<div class="channel-list">
|
||||
<app-channel-list [channels]="channels()" [selectedChannel]="selectedChannel()"
|
||||
(selectedChannelChange)="onSelectedChannelChange($event)">
|
||||
|
||||
</app-channel-list>
|
||||
</div>
|
||||
}
|
||||
<div class="chat">
|
||||
<div class="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>
|
||||
</main>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,99 @@
|
||||
.fullscreen-prompt-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgb(0 0 0 / 70%);
|
||||
}
|
||||
|
||||
.fullscreen-prompt {
|
||||
width: min(420px, 100%);
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
background: #1f1f1f;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 20px 60px rgb(0 0 0 / 45%);
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 22px;
|
||||
color: rgb(255 255 255 / 75%);
|
||||
}
|
||||
}
|
||||
|
||||
.fullscreen-prompt-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
background: #4f8cff;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
|
||||
&.secondary {
|
||||
background: rgb(255 255 255 / 14%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.channel-list {
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
.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 {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0 5px 0 5px;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {AfterViewInit, Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {ChatService} from '@pages/altitude/chat/service/chat.service';
|
||||
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {MiniMessageInteractionService} from '@pages/altitude/chat/mini-message/mini-message-interaction.service';
|
||||
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
|
||||
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({
|
||||
selector: 'app-chat',
|
||||
imports: [
|
||||
HeaderComponent,
|
||||
FullSizeComponent,
|
||||
ChatBoxComponent,
|
||||
ChannelListComponent,
|
||||
ChatInfoComponent,
|
||||
UserListComponent
|
||||
],
|
||||
templateUrl: './chat.component.html',
|
||||
styleUrl: './chat.component.scss',
|
||||
providers: [MiniMessageInteractionService]
|
||||
})
|
||||
export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private sub?: Subscription;
|
||||
private readonly chatService: ChatService = inject(ChatService)
|
||||
protected readonly messages = this.chatService.messages;
|
||||
protected readonly channels = this.chatService.channels;
|
||||
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}) => {
|
||||
if (action === 'run_command') {
|
||||
alert(value);
|
||||
}
|
||||
});
|
||||
this.breakpointObserver
|
||||
.observe([Breakpoints.Handset])
|
||||
.subscribe(result => {
|
||||
const isMobile = result.matches;
|
||||
this.isMobile.set(isMobile);
|
||||
this.showFullscreenPrompt.set(isMobile);
|
||||
if (isMobile) {
|
||||
this.userListVisible.set(false);
|
||||
} else {
|
||||
this.userListVisible.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onSelectedChannelChange(channel: ChatChannel) {
|
||||
this.chatService.selectChannel(channel);
|
||||
}
|
||||
|
||||
protected toggleUserList(): void {
|
||||
this.chatService.toggleUserList();
|
||||
}
|
||||
|
||||
protected enterFullscreen(): void {
|
||||
this.showFullscreenPrompt.set(false);
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {
|
||||
// Ignore fullscreen request failures.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected skipFullscreen(): void {
|
||||
this.showFullscreenPrompt.set(false);
|
||||
}
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.chatService.connect();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
document.documentElement.requestFullscreen().catch(() => {
|
||||
console.error('Failed to enter fullscreen');
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.sub?.unsubscribe();
|
||||
this.chatService.disconnect();
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {
|
||||
console.error('Failed to exit fullscreen');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<div class="channel-list">
|
||||
<p>Channels</p>
|
||||
<div class="scroll">
|
||||
@for (group of groupedChannels(); track group.type) {
|
||||
<p class="channel-group-title" (click)="toggleGroup(group.type)">
|
||||
{{ group.type }} {{ collapsedGroups().has(group.type) ? '▼' : '▲' }}
|
||||
</p>
|
||||
@if (!collapsedGroups().has(group.type)) {
|
||||
@for (channel of group.channels; track channel.name) {
|
||||
<div class="channel indented"
|
||||
[class.selected]="selectedChannel()?.name === channel.name && selectedChannel()?.type === channel.type"
|
||||
(click)="selectedChannelChange.emit(channel)">
|
||||
<span class="channel-name">
|
||||
@for (segment of getDisplayName(channel); track $index) {
|
||||
<span [style.color]="segment.color">{{ segment.text }}</span>
|
||||
}
|
||||
</span>
|
||||
@if (channel.unreadMessages > 0) {
|
||||
<div class="notification">
|
||||
<svg-circle></svg-circle>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
.channel-list {
|
||||
height: 100%;
|
||||
background-color: #172133;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.channel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid #222;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #202c42;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: #202c42;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.channel.indented {
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.channel-group-title {
|
||||
padding: 10px 20px;
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import {Component, computed, effect, inject, input, output, signal} from '@angular/core';
|
||||
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
|
||||
import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg';
|
||||
import {NameFormatService} from '@pages/altitude/chat/service/name-format.service';
|
||||
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list',
|
||||
imports: [
|
||||
CircleSvgComponent
|
||||
],
|
||||
templateUrl: './channel-list.component.html',
|
||||
styleUrl: './channel-list.component.scss'
|
||||
})
|
||||
export class ChannelListComponent {
|
||||
protected readonly nameFormatService: NameFormatService = inject(NameFormatService);
|
||||
public readonly channels = input.required<ChatChannel[]>();
|
||||
public readonly selectedChannel = input.required<ChatChannel | null>();
|
||||
public readonly selectedChannelChange = output<ChatChannel>();
|
||||
|
||||
protected readonly collapsedGroups = signal<Set<string>>(new Set());
|
||||
|
||||
protected toggleGroup(type: string) {
|
||||
const next = new Set(this.collapsedGroups());
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
this.collapsedGroups.set(next);
|
||||
}
|
||||
|
||||
protected readonly groupedChannels = computed(() => {
|
||||
const channels = this.channels();
|
||||
const groups: { type: ChatChannel['type'], channels: ChatChannel[] }[] = [];
|
||||
const types: ChatChannel['type'][] = ['SERVER', 'DM', 'PARTY', 'GAC', 'SPY'];
|
||||
|
||||
for (const type of types) {
|
||||
const typeChannels = channels.filter(c => c.type === type);
|
||||
if (typeChannels.length > 0) {
|
||||
groups.push({type, channels: typeChannels});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const chatChannels = this.channels();
|
||||
if (this.selectedChannel() === null && chatChannels.length > 0) {
|
||||
this.selectedChannelChange.emit(chatChannels[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
|
||||
return this.nameFormatService.getDisplayName(chatChannel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@if (selectedChannel(); as chatChannel) {
|
||||
<div class="full-width"
|
||||
[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()" [style.color]="getServerTextColor(chatChannel.name)"/>
|
||||
} @else {
|
||||
<svg-notification-disabled (click)="enableNotifications()" [style.color]="getServerTextColor(chatChannel.name)"/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
.full-width {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.channel {
|
||||
font-size: 20px;
|
||||
font-family: minecraft-text, 'opensans-bold', sans-serif;
|
||||
letter-spacing: 1px;
|
||||
font-weight: bold;
|
||||
padding-left: 10px;
|
||||
|
||||
.message-count {
|
||||
font-family: 'opensans-bold', sans-serif;
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-list-toggle {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
margin-right: 15px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 15px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {Component, computed, inject, input} from '@angular/core';
|
||||
import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/server-color';
|
||||
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
|
||||
import {NotificationEnabledSvgComponent} from '@pages/altitude/chat/svg/notification-enabled.svg';
|
||||
import {NotificationDisabledSvgComponent} from '@pages/altitude/chat/svg/notification-disabled.svg';
|
||||
import {UsersSvgComponent} from '@pages/altitude/chat/svg/users.svg';
|
||||
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
|
||||
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
|
||||
import {NameFormatService} from '@pages/altitude/chat/service/name-format.service';
|
||||
import {ChatService} from '@pages/altitude/chat/service/chat.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-info',
|
||||
imports: [
|
||||
NotificationEnabledSvgComponent,
|
||||
NotificationDisabledSvgComponent,
|
||||
UsersSvgComponent
|
||||
],
|
||||
templateUrl: './chat-info.component.html',
|
||||
styleUrl: './chat-info.component.scss'
|
||||
})
|
||||
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}` : '';
|
||||
});
|
||||
protected readonly notificationEnabled = computed(() => this.notificationService.isNotificationEnabled(this.channelKey()));
|
||||
protected readonly getServerColor = getServerColor;
|
||||
protected readonly getServerTextColor = getServerTextColor;
|
||||
|
||||
public enableNotifications() {
|
||||
this.notificationService.enableNotifications(this.channelKey());
|
||||
}
|
||||
|
||||
public disableNotifications() {
|
||||
this.notificationService.disableNotifications(this.channelKey());
|
||||
}
|
||||
|
||||
public toggleUserList() {
|
||||
this.chatService.toggleUserList();
|
||||
}
|
||||
|
||||
protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
|
||||
return this.nameFormatService.getDisplayName(chatChannel);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="message-container">
|
||||
@for (message of messages(); track message.timestamp) {
|
||||
<p class="message">
|
||||
<span class="date-time" [class.blocked]="!message.notBlocked">
|
||||
[{{ message.timestamp | date: 'mediumTime' }}]
|
||||
</span>
|
||||
@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>
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
|
||||
}
|
||||
|
||||
.message-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.message-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-container::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.message-container::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.blocked {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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,
|
||||
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();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
@if (node) {
|
||||
@if (node.isUrlLink) {
|
||||
<a
|
||||
[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"
|
||||
>{{ 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>
|
||||
}
|
||||
|
||||
@for (child of node.extra; track child) {
|
||||
<mini-message [node]="child"></mini-message>
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
.mm-node {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mm-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mm-link {
|
||||
text-decoration: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {Component, Input, Optional} from '@angular/core';
|
||||
import {ProcessedMiniMessageNode} from '../../mini-message/mini-message.types';
|
||||
import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive';
|
||||
import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service';
|
||||
|
||||
/**
|
||||
* Renders a Paper/Adventure component-JSON tree as styled HTML.
|
||||
*
|
||||
* Usage:
|
||||
* providers: [MiniMessageInteractionService] // once, at the root that hosts <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
|
||||
* natively as a real link so no subscription is needed for that case.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mini-message',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ObfuscatedDirective],
|
||||
templateUrl: './mini-message.component.html',
|
||||
styleUrl: './mini-message.component.scss',
|
||||
})
|
||||
export class MiniMessageComponent {
|
||||
@Input() node!: ProcessedMiniMessageNode;
|
||||
|
||||
constructor(@Optional() private readonly interaction: MiniMessageInteractionService | null) {
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
const click = this.node.clickEvent;
|
||||
if (!click) return;
|
||||
|
||||
if (click.action === 'copy_to_clipboard') {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { MiniMessageComponent, ResolvedStyle } from './mini-message.types';
|
||||
import { hoverEventToTitle, mergeStyle, ownText, styleToNgStyle } from './mini-message.util';
|
||||
|
||||
/**
|
||||
* Escapes text for safe insertion into HTML. Always used on raw text content —
|
||||
* never skip this when building the string yourself.
|
||||
*/
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function ngStyleToCss(style: Record<string, string>): string {
|
||||
return Object.entries(style)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a component-JSON tree to an HTML string, e.g. for binding via
|
||||
* [innerHTML]="miniMessageToHtml(json) | ..." with Angular's DomSanitizer, or for use
|
||||
* outside Angular entirely. Prefer the <mini-message> component when you need click/hover
|
||||
* interactivity or the obfuscated animation — this string form is static (obfuscated text
|
||||
* renders as a fixed placeholder rather than an animated one).
|
||||
*/
|
||||
export function miniMessageToHtml(
|
||||
node: MiniMessageComponent,
|
||||
parentStyle?: ResolvedStyle
|
||||
): string {
|
||||
const style = mergeStyle(parentStyle, node);
|
||||
const css = ngStyleToCss(styleToNgStyle(style));
|
||||
const text = escapeHtml(ownText(node));
|
||||
const title = hoverEventToTitle(node.hoverEvent);
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
const insertionAttr = node.insertion ? ` data-insertion="${escapeHtml(node.insertion)}"` : '';
|
||||
|
||||
let ownHtml: string;
|
||||
if (node.clickEvent?.action === 'open_url') {
|
||||
ownHtml = `<a href="${escapeHtml(
|
||||
node.clickEvent.value
|
||||
)}" target="_blank" rel="noopener noreferrer" class="mm-node mm-link" style="${css}"${titleAttr}${insertionAttr}>${text}</a>`;
|
||||
} else {
|
||||
const clickAttrs = node.clickEvent
|
||||
? ` class="mm-node mm-clickable" data-click-action="${node.clickEvent.action}" data-click-value="${escapeHtml(
|
||||
node.clickEvent.value
|
||||
)}"`
|
||||
: ' class="mm-node"';
|
||||
ownHtml = `<span${clickAttrs} style="${css}"${titleAttr}${insertionAttr}>${text}</span>`;
|
||||
}
|
||||
|
||||
const childrenHtml = (node.extra ?? []).map((child) => miniMessageToHtml(child, style)).join('');
|
||||
return ownHtml + childrenHtml;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { MiniMessageClickPayload } from './mini-message.types';
|
||||
|
||||
/**
|
||||
* Because <mini-message> renders itself recursively for `extra` children, a plain
|
||||
* @Output() would need to be manually re-emitted at every level. Instead, every
|
||||
* instance injects this single service (provided once at the root usage) and pushes
|
||||
* clicks through it. Subscribe to `clicks$` wherever you render the top-level component.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MiniMessageInteractionService {
|
||||
private readonly clickSubject = new Subject<MiniMessageClickPayload>();
|
||||
readonly clicks$ = this.clickSubject.asObservable();
|
||||
|
||||
emitClick(payload: MiniMessageClickPayload): void {
|
||||
this.clickSubject.next(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { MiniMessageComponent } from './mini-message.types';
|
||||
import { miniMessageToHtml } from './mini-message-html.util';
|
||||
|
||||
/**
|
||||
* <div [innerHTML]="componentJson | miniMessage"></div>
|
||||
*
|
||||
* Simpler than <mini-message> but static: no obfuscation animation, and clicks on
|
||||
* non-URL actions (run_command, etc.) need to be wired up yourself via a (click) handler
|
||||
* on the container using event delegation, reading data-click-action / data-click-value
|
||||
* off event.target.
|
||||
*/
|
||||
@Pipe({
|
||||
name: 'miniMessage',
|
||||
standalone: true,
|
||||
})
|
||||
export class MiniMessagePipe implements PipeTransform {
|
||||
constructor(private readonly sanitizer: DomSanitizer) {}
|
||||
|
||||
transform(node: MiniMessageComponent | null | undefined): SafeHtml {
|
||||
if (!node) return '';
|
||||
return this.sanitizer.bypassSecurityTrustHtml(miniMessageToHtml(node));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Types for Paper/Adventure "Component" JSON — this is the format you get when a
|
||||
* MiniMessage string is parsed on the server and serialized with GsonComponentSerializer.
|
||||
* By the time it reaches your frontend, tags like <gradient>, <rainbow>, <color>, etc.
|
||||
* have already been resolved into a tree of styled text runs (this is why you don't see
|
||||
* "<red>" anywhere in the JSON — you see {"color":"red", ...} instead).
|
||||
*/
|
||||
|
||||
export type ClickAction =
|
||||
| 'open_url'
|
||||
| 'open_file'
|
||||
| 'run_command'
|
||||
| 'suggest_command'
|
||||
| 'change_page'
|
||||
| 'copy_to_clipboard';
|
||||
|
||||
export interface MiniMessageClickEvent {
|
||||
action: ClickAction;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type HoverAction = 'show_text' | 'show_item' | 'show_entity';
|
||||
|
||||
export interface MiniMessageHoverEvent {
|
||||
action: HoverAction;
|
||||
/** Modern (1.16+) format */
|
||||
value?: MiniMessageComponent | string;
|
||||
/** Legacy format some plugins/serializers still emit for show_text */
|
||||
contents?: MiniMessageComponent | string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single node in the component tree. Every field is optional because children only need
|
||||
* to specify what they *override* — everything else is inherited from the parent (color,
|
||||
* bold, italic, underlined, strikethrough, obfuscated, font, clickEvent, hoverEvent, insertion).
|
||||
*/
|
||||
export interface MiniMessageComponent {
|
||||
/** Plain text component */
|
||||
text?: string;
|
||||
|
||||
/** Translatable component (e.g. vanilla "chat.type.text") */
|
||||
translate?: string;
|
||||
/** Substitution arguments for `translate`, in order */
|
||||
with?: MiniMessageComponent[];
|
||||
/** Fallback text if no translation is registered for `translate` */
|
||||
fallback?: string;
|
||||
|
||||
/** Keybind component, e.g. "key.jump" */
|
||||
keybind?: string;
|
||||
|
||||
/** Score component (scoreboard value) */
|
||||
score?: { name: string; objective: string; value?: string };
|
||||
|
||||
/** Selector component, e.g. "@a" */
|
||||
selector?: string;
|
||||
|
||||
/** Named color ("red", "aqua", ...), hex ("#RRGGBB"), or "reset" */
|
||||
color?: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underlined?: boolean;
|
||||
strikethrough?: boolean;
|
||||
obfuscated?: boolean;
|
||||
font?: string;
|
||||
|
||||
/** Text inserted into chat input on shift-click (Minecraft-client-only concept) */
|
||||
insertion?: string;
|
||||
|
||||
clickEvent?: MiniMessageClickEvent;
|
||||
hoverEvent?: MiniMessageHoverEvent;
|
||||
|
||||
/** Child components, styled relative to this node */
|
||||
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;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underlined: boolean;
|
||||
strikethrough: boolean;
|
||||
obfuscated: boolean;
|
||||
font?: string;
|
||||
}
|
||||
|
||||
export interface MiniMessageClickPayload {
|
||||
action: ClickAction;
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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> = {
|
||||
black: '#000000',
|
||||
dark_blue: '#0000AA',
|
||||
dark_green: '#00AA00',
|
||||
dark_aqua: '#00AAAA',
|
||||
dark_red: '#AA0000',
|
||||
dark_purple: '#AA00AA',
|
||||
gold: '#FFAA00',
|
||||
gray: '#AAAAAA',
|
||||
dark_gray: '#555555',
|
||||
blue: '#5555FF',
|
||||
green: '#55FF55',
|
||||
aqua: '#55FFFF',
|
||||
red: '#FF5555',
|
||||
light_purple: '#FF55FF',
|
||||
yellow: '#FFFF55',
|
||||
white: '#FFFFFF',
|
||||
};
|
||||
|
||||
/** Some servers map custom fonts to real web fonts; extend this as needed */
|
||||
const FONT_MAP: Record<string, string> = {
|
||||
'minecraft:default': 'inherit',
|
||||
'minecraft:uniform': 'inherit',
|
||||
'minecraft:alt': "'Minecraft Enchantment', inherit",
|
||||
};
|
||||
|
||||
export const DEFAULT_STYLE: ResolvedStyle = {
|
||||
color: undefined,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underlined: false,
|
||||
strikethrough: false,
|
||||
obfuscated: false,
|
||||
font: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a color field ("red" | "#RRGGBB" | "reset" | undefined) to a CSS color or
|
||||
* undefined (meaning: unset / inherit).
|
||||
*/
|
||||
export function resolveColor(color: string | undefined): string | undefined {
|
||||
if (!color) return undefined;
|
||||
if (color === 'reset') return undefined;
|
||||
if (color.startsWith('#')) return color;
|
||||
return NAMED_COLORS[color] ?? undefined;
|
||||
}
|
||||
|
||||
function resolveFont(font: string | undefined): string | undefined {
|
||||
if (!font) return undefined;
|
||||
return FONT_MAP[font] ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a node's own style declarations on top of its parent's resolved style.
|
||||
* Adventure semantics: a field that is present on the node overrides the parent
|
||||
* (including explicit `false`); a field that is absent (undefined) is inherited.
|
||||
*/
|
||||
export function mergeStyle(parent: ResolvedStyle | undefined, node: MiniMessageComponent): ResolvedStyle {
|
||||
const base = parent ?? DEFAULT_STYLE;
|
||||
return {
|
||||
color: node.color !== undefined ? resolveColor(node.color) : base.color,
|
||||
bold: node.bold !== undefined ? node.bold : base.bold,
|
||||
italic: node.italic !== undefined ? node.italic : base.italic,
|
||||
underlined: node.underlined !== undefined ? node.underlined : base.underlined,
|
||||
strikethrough: node.strikethrough !== undefined ? node.strikethrough : base.strikethrough,
|
||||
obfuscated: node.obfuscated !== undefined ? node.obfuscated : base.obfuscated,
|
||||
font: node.font !== undefined ? resolveFont(node.font) : base.font,
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a resolved style into an Angular [ngStyle]-compatible object */
|
||||
export function styleToNgStyle(style: ResolvedStyle): Record<string, string> {
|
||||
const decorations: string[] = [];
|
||||
if (style.underlined) decorations.push('underline');
|
||||
if (style.strikethrough) decorations.push('line-through');
|
||||
|
||||
return {
|
||||
color: style.color ?? 'inherit',
|
||||
'font-weight': style.bold ? 'bold' : 'normal',
|
||||
'font-style': style.italic ? 'italic' : 'normal',
|
||||
'text-decoration-line': decorations.length ? decorations.join(' ') : 'none',
|
||||
'font-family': style.font ?? 'inherit',
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the node's own displayable text (does not recurse into `extra`) */
|
||||
export function ownText(node: MiniMessageComponent): string {
|
||||
if (node.text !== undefined) return node.text;
|
||||
if (node.translate !== undefined) return node.fallback ?? `%${node.translate}%`;
|
||||
if (node.keybind !== undefined) return `[${node.keybind}]`;
|
||||
if (node.selector !== undefined) return node.selector;
|
||||
if (node.score !== undefined) return node.score.value ?? '';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Recursively flattens a component tree (including `extra`) into plain text */
|
||||
export function extractPlainText(node: MiniMessageComponent | string | undefined): string {
|
||||
if (node === undefined) return '';
|
||||
if (typeof node === 'string') return node;
|
||||
let out = ownText(node);
|
||||
if (node.extra) {
|
||||
for (const child of node.extra) {
|
||||
out += extractPlainText(child);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Extracts plain text to use as a native `title` tooltip for a hoverEvent */
|
||||
export function hoverEventToTitle(hover: MiniMessageHoverEvent | undefined): string | null {
|
||||
if (!hover) return null;
|
||||
if (hover.action === 'show_text') {
|
||||
const source = hover.value ?? hover.contents;
|
||||
if (typeof source === 'string') return source;
|
||||
if (source && typeof source === 'object' && ('text' in source || 'translate' in source || 'extra' in source)) {
|
||||
return extractPlainText(source as MiniMessageComponent);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (hover.action === 'show_item') {
|
||||
const contents = hover.contents as Record<string, unknown> | undefined;
|
||||
const id = (contents?.['id'] as string) ?? (hover.value as unknown as { id?: string })?.id;
|
||||
return id ? `Item: ${id}` : 'Item';
|
||||
}
|
||||
if (hover.action === 'show_entity') {
|
||||
const contents = hover.contents as Record<string, unknown> | undefined;
|
||||
const type = (contents?.['type'] as string) ?? '';
|
||||
return type ? `Entity: ${type}` : 'Entity';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computes a component tree into a ProcessedMiniMessageNode tree.
|
||||
* This resolves all styles, texts, and attributes once so they don't have to be
|
||||
* re-computed on every render.
|
||||
*/
|
||||
export function precomputeNode(node: MiniMessageComponent, parentStyle?: ResolvedStyle): ProcessedMiniMessageNode {
|
||||
const style = mergeStyle(parentStyle, node);
|
||||
const isUrlLink = node.clickEvent?.action === 'open_url';
|
||||
|
||||
return {
|
||||
text: ownText(node),
|
||||
style,
|
||||
ngStyle: styleToNgStyle(style),
|
||||
hoverTitle: hoverEventToTitle(node.hoverEvent),
|
||||
isUrlLink,
|
||||
hasNonUrlClick: !!node.clickEvent && !isUrlLink,
|
||||
clickEvent: node.clickEvent,
|
||||
insertion: node.insertion,
|
||||
extra: (node.extra ?? []).map((child) => precomputeNode(child, style)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {MiniMessageClickEvent, MiniMessageComponent, MiniMessageHoverEvent} from './mini-message.types';
|
||||
|
||||
/**
|
||||
* What actually arrives over the wire can be looser than the strict MiniMessageComponent
|
||||
* type: some serializers emit bare strings inside `extra` instead of {text: "..."}, and
|
||||
* some use snake_case event keys (hover_event/click_event) with different subfield names
|
||||
* (e.g. click_event.command instead of clickEvent.value). This function tolerates both
|
||||
* and always returns a clean, strictly-typed tree.
|
||||
*
|
||||
* Call this once, right after JSON.parse, before the data enters your app's state:
|
||||
*
|
||||
* messageJson: normalizeComponent(JSON.parse(m.messageJson))
|
||||
*/
|
||||
export function normalizeComponent(input: unknown): MiniMessageComponent {
|
||||
if (typeof input === 'string') {
|
||||
return {text: input};
|
||||
}
|
||||
if (input === null || typeof input !== 'object') {
|
||||
return {text: ''};
|
||||
}
|
||||
|
||||
const raw = input as Record<string, any>;
|
||||
const node: MiniMessageComponent = {...(raw as MiniMessageComponent)};
|
||||
|
||||
node.clickEvent = normalizeClickEvent(raw['clickEvent'] ?? raw['click_event']);
|
||||
node.hoverEvent = normalizeHoverEvent(raw['hoverEvent'] ?? raw['hover_event']);
|
||||
delete node['click_event' as keyof MiniMessageComponent];
|
||||
delete node['hover_event' as keyof MiniMessageComponent];
|
||||
|
||||
if (Array.isArray(raw['extra'])) {
|
||||
node.extra = raw['extra'].map((child: unknown) => normalizeComponent(child));
|
||||
}
|
||||
|
||||
// auditShape(node);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function normalizeClickEvent(raw: Record<string, any> | undefined): MiniMessageClickEvent | undefined {
|
||||
if (!raw) return undefined;
|
||||
const value = raw['value'] ?? raw['command'] ?? raw['url'] ?? raw['page'] ?? '';
|
||||
return {action: raw['action'], value: String(value)};
|
||||
}
|
||||
|
||||
function normalizeHoverEvent(raw: Record<string, any> | undefined): MiniMessageHoverEvent | undefined {
|
||||
if (!raw) return undefined;
|
||||
const source = raw['value'] ?? raw['contents'];
|
||||
return {
|
||||
action: raw['action'],
|
||||
value: typeof source === 'string' ? source : source ? normalizeComponent(source) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function auditShape(node: unknown, path = 'root'): void {
|
||||
if (typeof node === 'string') {
|
||||
console.warn(`Bare string in tree at ${path}:`, node);
|
||||
return;
|
||||
}
|
||||
const n = node as Record<string, unknown>;
|
||||
if (n['hover_event'] || n['click_event']) {
|
||||
console.warn(`snake_case event key at ${path}`, n);
|
||||
}
|
||||
(n['extra'] as unknown[] | undefined)?.forEach((c, i) => auditShape(c, `${path}.extra[${i}]`));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Directive, ElementRef, Input, OnChanges, OnDestroy } from '@angular/core';
|
||||
|
||||
const OBFUSCATION_CHARS =
|
||||
'!@#$%^&*()_+-=[]{}|;:,.<>?/~`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
/**
|
||||
* Apply as `[mmObfuscated]="style.obfuscated"` on an element whose textContent is the
|
||||
* text to obfuscate. While active, cycles every character (except spaces) through random
|
||||
* glyphs, mimicking Minecraft's "magic" formatting. The real text is preserved in an
|
||||
* aria-label so it stays accessible to screen readers, and is restored to the DOM when
|
||||
* obfuscation is turned off.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[mmObfuscated]',
|
||||
standalone: true,
|
||||
})
|
||||
export class ObfuscatedDirective implements OnChanges, OnDestroy {
|
||||
@Input('mmObfuscated') active = false;
|
||||
|
||||
private original: string | null = null;
|
||||
private intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
constructor(private readonly el: ElementRef<HTMLElement>) {}
|
||||
|
||||
ngOnChanges(): void {
|
||||
if (this.original === null) {
|
||||
this.original = this.el.nativeElement.textContent ?? '';
|
||||
this.el.nativeElement.setAttribute('aria-label', this.original);
|
||||
}
|
||||
this.sync();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
private sync(): void {
|
||||
if (this.active && !this.intervalId) {
|
||||
this.start();
|
||||
} else if (!this.active && this.intervalId) {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
this.intervalId = setInterval(() => {
|
||||
const text = this.original ?? '';
|
||||
const scrambled = text
|
||||
.split('')
|
||||
.map((ch) => (ch === ' ' ? ' ' : OBFUSCATION_CHARS[Math.floor(Math.random() * OBFUSCATION_CHARS.length)]))
|
||||
.join('');
|
||||
this.el.nativeElement.textContent = scrambled;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
if (this.original !== null) {
|
||||
this.el.nativeElement.textContent = this.original;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ChatChannel {
|
||||
name: string;
|
||||
type: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY';
|
||||
unreadMessages: number;
|
||||
totalMessages: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {ProcessedMiniMessageNode} from '@pages/altitude/chat/mini-message/mini-message.types';
|
||||
|
||||
export interface ChatMessage {
|
||||
uuid: string;
|
||||
timestamp: number;
|
||||
server: string;
|
||||
type: string;
|
||||
channel: string;
|
||||
receiver: string;
|
||||
miniMessageNode: ProcessedMiniMessageNode;
|
||||
notBlocked: boolean;
|
||||
channelName?: string;
|
||||
channelType?: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ColoredNameSegment {
|
||||
text: string;
|
||||
color?: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
|
||||
|
||||
export interface RawChatMessage extends Omit<ChatMessage, 'messageJson'> {
|
||||
messageJson: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface User {
|
||||
uuid: string;
|
||||
name: string;
|
||||
styledName: string;
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
name: string;
|
||||
players: User[];
|
||||
}
|
||||
|
||||
export interface ServerState {
|
||||
servers: Server[];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {Injectable, signal} from '@angular/core';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class NotificationService {
|
||||
|
||||
private readonly notificationSound = new Audio('/public/sounds/notification.mp3');
|
||||
private readonly notificationStorageKey = 'chat-notifications-enabled';
|
||||
private readonly _notificationEnabled = signal<Record<string, boolean>>(
|
||||
this.getStoredNotificationSettings()
|
||||
);
|
||||
|
||||
public notify(channel: string) {
|
||||
if (!this.isNotificationEnabled(channel)) {
|
||||
return;
|
||||
}
|
||||
this.playSound();
|
||||
|
||||
}
|
||||
|
||||
private enableSound() {
|
||||
this.notificationSound.muted = true;
|
||||
this.notificationSound.play()
|
||||
.then(() => {
|
||||
this.notificationSound.pause();
|
||||
this.notificationSound.currentTime = 0;
|
||||
this.notificationSound.muted = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('Could not unlock notification sound:', error);
|
||||
this.notificationSound.muted = false;
|
||||
});
|
||||
}
|
||||
|
||||
private playSound() {
|
||||
this.notificationSound.currentTime = 0;
|
||||
this.notificationSound.play().catch((error) => {
|
||||
console.log('Could not play notification sound:', error);
|
||||
});
|
||||
}
|
||||
|
||||
public isNotificationEnabled(channel: string): boolean {
|
||||
return this._notificationEnabled()[channel];
|
||||
}
|
||||
|
||||
public enableNotifications(channel: string) {
|
||||
this.setNotificationEnabled(channel, true);
|
||||
this.enableSound()
|
||||
}
|
||||
|
||||
public disableNotifications(channel: string) {
|
||||
this.setNotificationEnabled(channel, false);
|
||||
}
|
||||
|
||||
private setNotificationEnabled(channel: string, enabled: boolean) {
|
||||
const notificationSettings = {
|
||||
...this._notificationEnabled(),
|
||||
[channel]: enabled
|
||||
};
|
||||
|
||||
this._notificationEnabled.set(notificationSettings);
|
||||
localStorage.setItem(this.notificationStorageKey, JSON.stringify(notificationSettings));
|
||||
}
|
||||
|
||||
private getStoredNotificationSettings(): Record<string, boolean> {
|
||||
const storedSettings = localStorage.getItem(this.notificationStorageKey);
|
||||
|
||||
if (storedSettings === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (storedSettings === 'true' || storedSettings === 'false') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(storedSettings) as Record<string, boolean>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@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 [];
|
||||
return this._messages()
|
||||
.filter((message) => message.channelName === selected.name && message.channelType === selected.type)
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
});
|
||||
|
||||
connect() {
|
||||
if (this.eventSource) {
|
||||
return; // already connected
|
||||
}
|
||||
|
||||
const jwt = this.authService.getJwt();
|
||||
if (!jwt) {
|
||||
//TODO [Stijn] [2026-07-18]: Error when no JWT available (log in?)
|
||||
return;
|
||||
}
|
||||
|
||||
const source = new EventSourcePolyfill('/api/chat/read/subscribe', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`
|
||||
},
|
||||
heartbeatTimeout: 60000
|
||||
});
|
||||
|
||||
this.eventSource = source;
|
||||
|
||||
this.on(source, 'connected', (event) => {
|
||||
this.processChatMessages(event);
|
||||
this._connected.set(true);
|
||||
this.restoreVisibilityAfterConnection();
|
||||
this.startVisibilityPolling();
|
||||
});
|
||||
|
||||
this.on(source, 'chat', (event) => {
|
||||
const chatMessages = this.processChatMessages(event);
|
||||
chatMessages.forEach((message) => {
|
||||
const selected = this._selectedChannel();
|
||||
const isSelected = selected && selected.name === message.channelName && selected.type === message.channelType;
|
||||
|
||||
const find = this._channels().find(channel => message.channelName === channel.name && message.channelType === channel.type && !isSelected);
|
||||
if (find) {
|
||||
find.unreadMessages++;
|
||||
}
|
||||
})
|
||||
new Set(chatMessages.map(message => `${message.channelType}:${message.channelName}`))
|
||||
.forEach((channelKeyStr) => {
|
||||
this.notificationService.notify(channelKeyStr);
|
||||
});
|
||||
});
|
||||
|
||||
this.on(source, 'server-state', (event) => {
|
||||
const state = JSON.parse(event.data) as ServerState;
|
||||
this._serverState.set(state);
|
||||
});
|
||||
|
||||
source.onerror = (err) => {
|
||||
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,
|
||||
miniMessageNode: precomputeNode(normalizeComponent(JSON.parse(m.messageJson))),
|
||||
} as ChatMessage;
|
||||
const channelKey = this.getChannelKey(message);
|
||||
if (channelKey === null) {
|
||||
return null;
|
||||
}
|
||||
message.channelName = channelKey.name;
|
||||
message.channelType = channelKey.type;
|
||||
return message;
|
||||
}).filter((message) => message !== null) as ChatMessage[];
|
||||
this._messages.update((old) => [...old, ...messages]);
|
||||
|
||||
messages.forEach((message) => {
|
||||
const found = this._channels().find(existing => existing.name === message.channelName && existing.type === message.channelType);
|
||||
if (!found) {
|
||||
this._channels.update((old) => [...old, {
|
||||
name: message.channelName!,
|
||||
type: message.channelType!,
|
||||
unreadMessages: 0,
|
||||
totalMessages: 1
|
||||
}]);
|
||||
} else {
|
||||
found.totalMessages++;
|
||||
}
|
||||
})
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private getChannelKey(message: ChatMessage): { name: string, type: ChatChannel['type'] } | null {
|
||||
if (message.type === 'GAC') {
|
||||
return {name: 'Global Admin Chat', type: 'GAC'};
|
||||
}
|
||||
if (message.type === 'PARTY') {
|
||||
if (!message.channel) {
|
||||
throw new Error('Party name is missing');
|
||||
}
|
||||
this.updatePartyMap(message.channel)
|
||||
return {name: message.channel, type: 'PARTY'};
|
||||
}
|
||||
if (message.type === 'MSG') {
|
||||
const uuid = this.authService.getUuid();
|
||||
if (message.receiver === uuid) {
|
||||
this.updateUserNameMap(message.uuid)
|
||||
return {name: message.uuid, type: 'DM'};
|
||||
}
|
||||
this.updateUserNameMap(message.receiver)
|
||||
return {name: message.receiver, type: 'SPY'};
|
||||
}
|
||||
return {name: message.server, type: 'SERVER'};
|
||||
}
|
||||
|
||||
public updatePartyMap(partyId: string): void {
|
||||
if (this.partieMap().has(partyId) || this.pendingPartyNameRequests.has(partyId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingPartyNameRequests.add(partyId);
|
||||
|
||||
this.chatInfoService.getPartyName(partyId).subscribe({
|
||||
next: (partyName) => {
|
||||
this.partieMap.update((old) => {
|
||||
const newMap = new Map(old);
|
||||
newMap.set(partyId, partyName.name);
|
||||
return newMap;
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(`Failed to load party name for ${partyId}:`, error);
|
||||
},
|
||||
complete: () => {
|
||||
this.pendingPartyNameRequests.delete(partyId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public updateUserNameMap(uuid: string): void {
|
||||
if (this.userNameMap().has(uuid) || this.pendingUserNameRequests.has(uuid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingUserNameRequests.add(uuid);
|
||||
|
||||
this.chatInfoService.getName(uuid).subscribe({
|
||||
next: (userName) => {
|
||||
this.userNameMap.update((old) => {
|
||||
const newMap = new Map(old);
|
||||
newMap.set(uuid, userName.nickname ?? userName.name);
|
||||
return newMap;
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(`Failed to load username for ${uuid}:`, error);
|
||||
},
|
||||
complete: () => {
|
||||
this.pendingUserNameRequests.delete(uuid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public selectChannel(channel: ChatChannel) {
|
||||
this._selectedChannel.set(channel);
|
||||
this._channels.update((old) =>
|
||||
old.map((c) =>
|
||||
c.name === channel.name && c.type === channel.type
|
||||
? {...c, unreadMessages: 0}
|
||||
: c
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// single, deliberate escape hatch from the broken DOM/polyfill type overlap —
|
||||
// everything else in this file stays fully typed
|
||||
private on(source: EventSourcePolyfill, eventName: string, handler: (event: SsePayloadEvent) => void): void {
|
||||
(source as unknown as { addEventListener: (type: string, listener: (event: SsePayloadEvent) => void) => void })
|
||||
.addEventListener(eventName, handler);
|
||||
}
|
||||
|
||||
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 {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {NameFormatService} from './name-format.service';
|
||||
import {ChatService} from './chat.service';
|
||||
|
||||
describe('NameFormatService', () => {
|
||||
let service: NameFormatService;
|
||||
let chatServiceSpy: jasmine.SpyObj<ChatService>;
|
||||
|
||||
beforeEach(() => {
|
||||
chatServiceSpy = jasmine.createSpyObj('ChatService', ['partieMap', 'userNameMap']);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
NameFormatService,
|
||||
{provide: ChatService, useValue: chatServiceSpy}
|
||||
]
|
||||
});
|
||||
service = TestBed.inject(NameFormatService);
|
||||
});
|
||||
|
||||
it('should parse legacy color codes', () => {
|
||||
const input = '&4Hello &cWorld';
|
||||
const result = (service as any).parseColoredName(input);
|
||||
expect(result).toEqual([
|
||||
{text: 'Hello ', color: '#AA0000'},
|
||||
{text: 'World', color: '#FF5555'}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse mixed color codes', () => {
|
||||
const input = '{#AA0000<>}Hello &4World';
|
||||
const result = (service as any).parseColoredName(input);
|
||||
expect(result).toEqual([
|
||||
{text: 'Hello ', color: '#AA0000'},
|
||||
{text: 'World', color: '#AA0000'}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
|
||||
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
|
||||
import {ChatService} from "./chat.service";
|
||||
import {User} from '@pages/altitude/chat/objects/server-state.object';
|
||||
import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util';
|
||||
import {precomputeNode} from '@pages/altitude/chat/mini-message/mini-message.util';
|
||||
import {ProcessedMiniMessageNode} from '@pages/altitude/chat/mini-message/mini-message.types';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class NameFormatService {
|
||||
|
||||
private readonly chatService: ChatService = inject(ChatService);
|
||||
|
||||
public getDisplayName(channel: ChatChannel): ColoredNameSegment[] {
|
||||
return this.parseColoredName(this.getNameFromType(channel));
|
||||
}
|
||||
|
||||
public getUserDisplayName(user: User): ProcessedMiniMessageNode {
|
||||
if (user.styledName) {
|
||||
try {
|
||||
return precomputeNode(normalizeComponent(JSON.parse(user.styledName)));
|
||||
} catch {
|
||||
return precomputeNode(normalizeComponent({text: user.name}));
|
||||
}
|
||||
}
|
||||
|
||||
return precomputeNode(normalizeComponent({text: user.name}));
|
||||
}
|
||||
|
||||
private readonly LEGACY_COLOR_MAP: Record<string, string> = {
|
||||
'4': '#AA0000',
|
||||
'c': '#FF5555',
|
||||
'6': '#FFAA00',
|
||||
'e': '#FFFF55',
|
||||
'2': '#00AA00',
|
||||
'a': '#55FF55',
|
||||
'b': '#55FFFF',
|
||||
'3': '#00AAAA',
|
||||
'1': '#0000AA',
|
||||
'9': '#5555FF',
|
||||
'd': '#FF55FF',
|
||||
'5': '#AA00AA',
|
||||
'f': '#FFFFFF',
|
||||
'7': '#AAAAAA',
|
||||
'8': '#555555',
|
||||
'0': '#000000'
|
||||
};
|
||||
|
||||
private getNameFromType(channel: ChatChannel): string {
|
||||
if (channel.type === 'PARTY') {
|
||||
return this.chatService.partieMap().get(channel.name) ?? channel.name;
|
||||
}
|
||||
if (channel.type === 'DM' || channel.type === 'SPY') {
|
||||
return this.chatService.userNameMap().get(channel.name) ?? channel.name;
|
||||
}
|
||||
if (channel.type === 'SERVER') {
|
||||
return channel.name.charAt(0).toUpperCase() + channel.name.slice(1);
|
||||
}
|
||||
return channel.name;
|
||||
}
|
||||
|
||||
private parseColoredName(name: string): ColoredNameSegment[] {
|
||||
const markerRegex = /\{#([0-9a-fA-F]{6})(<>|[<>]?)\}|&([0-9a-fA-F])/g;
|
||||
const segments: ColoredNameSegment[] = [];
|
||||
let currentColor: string | undefined;
|
||||
let gradientStartColor: string | undefined;
|
||||
let gradientStartSegmentIndex: number | undefined;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = markerRegex.exec(name)) !== null) {
|
||||
this.addColoredTextSegment(segments, name.slice(lastIndex, match.index), currentColor);
|
||||
|
||||
const isLegacy = !!match[3];
|
||||
const markerColor = isLegacy ? this.LEGACY_COLOR_MAP[match[3]] : `#${match[1]}`;
|
||||
const markerType = isLegacy ? undefined : match[2];
|
||||
|
||||
if (markerType === '>') {
|
||||
currentColor = markerColor;
|
||||
gradientStartColor = markerColor;
|
||||
gradientStartSegmentIndex = segments.length;
|
||||
} else if (markerType === '<') {
|
||||
if (gradientStartColor !== undefined && gradientStartSegmentIndex !== undefined) {
|
||||
this.applyGradient(segments, gradientStartSegmentIndex, gradientStartColor, markerColor);
|
||||
}
|
||||
currentColor = markerColor;
|
||||
gradientStartColor = undefined;
|
||||
gradientStartSegmentIndex = undefined;
|
||||
} else if (markerType === '<>') {
|
||||
if (gradientStartColor !== undefined && gradientStartSegmentIndex !== undefined) {
|
||||
this.applyGradient(segments, gradientStartSegmentIndex, gradientStartColor, markerColor);
|
||||
}
|
||||
currentColor = markerColor;
|
||||
gradientStartColor = markerColor;
|
||||
gradientStartSegmentIndex = segments.length;
|
||||
} else {
|
||||
currentColor = markerColor;
|
||||
gradientStartColor = undefined;
|
||||
gradientStartSegmentIndex = undefined;
|
||||
}
|
||||
|
||||
lastIndex = markerRegex.lastIndex;
|
||||
}
|
||||
|
||||
this.addColoredTextSegment(segments, name.slice(lastIndex), currentColor);
|
||||
|
||||
return segments.length > 0 ? segments : [{text: name}];
|
||||
}
|
||||
|
||||
private addColoredTextSegment(segments: ColoredNameSegment[], text: string, color?: string): void {
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
segments.push({text, color});
|
||||
}
|
||||
|
||||
private applyGradient(
|
||||
segments: ColoredNameSegment[],
|
||||
startSegmentIndex: number,
|
||||
startColor: string,
|
||||
endColor: string
|
||||
): void {
|
||||
const text = segments.slice(startSegmentIndex).map((segment) => segment.text).join('');
|
||||
const length = text.length;
|
||||
const start = this.hexToRgb(startColor);
|
||||
const end = this.hexToRgb(endColor);
|
||||
|
||||
if (length === 0 || start === null || end === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gradientSegments: ColoredNameSegment[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const ratio = length === 1 ? 0 : i / (length - 1);
|
||||
gradientSegments.push({
|
||||
text: text[i],
|
||||
color: this.rgbToHex(
|
||||
start.r + (end.r - start.r) * ratio,
|
||||
start.g + (end.g - start.g) * ratio,
|
||||
start.b + (end.b - start.b) * ratio
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
segments.splice(startSegmentIndex, segments.length - startSegmentIndex, ...gradientSegments);
|
||||
}
|
||||
|
||||
private hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const match = /^#?([0-9a-fA-F]{6})$/.exec(hex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = Number.parseInt(match[1], 16);
|
||||
return {
|
||||
r: (value >> 16) & 255,
|
||||
g: (value >> 8) & 255,
|
||||
b: value & 255
|
||||
};
|
||||
}
|
||||
|
||||
private rgbToHex(r: number, g: number, b: number): string {
|
||||
return `#${[r, g, b]
|
||||
.map((value) => Math.round(value).toString(16).padStart(2, '0'))
|
||||
.join('')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'svg-circle',
|
||||
imports: [],
|
||||
template: `
|
||||
<svg
|
||||
width="10px"
|
||||
height="10px" viewBox="0 0 6 6" id="meteor-icon-kit__solid-circle-xxxs" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M3 6C1.3431 6 0 4.6569 0 3C0 1.3431 1.3431 0 3 0C4.6569 0 6 1.3431 6 3C6 4.6569 4.6569 6 3 6z"
|
||||
fill="currentColor"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
export class CircleSvgComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'svg-notification-disabled',
|
||||
imports: [],
|
||||
template: `
|
||||
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 3L21 21M9.37747 3.56325C10.1871 3.19604 11.0827 3 12 3C13.5913 3 15.1174 3.59 16.2426 4.6402C17.3679 5.69041 18 7.11479 18 8.6C18 10.3566 18.2892 11.7759 18.712 12.9122M17 17H15M6.45339 6.46451C6.15686 7.13542 6 7.86016 6 8.6C6 11.2862 5.3238 13.1835 4.52745 14.4866C3.75616 15.7486 3.37051 16.3797 3.38485 16.5436C3.40095 16.7277 3.43729 16.7925 3.58603 16.9023C3.71841 17 4.34762 17 5.60605 17H9M9 17V18C9 19.6569 10.3431 21 12 21C13.6569 21 15 19.6569 15 18V17M9 17H15"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
export class NotificationDisabledSvgComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'svg-notification-enabled',
|
||||
imports: [],
|
||||
template: `
|
||||
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.00195 17H5.60636C4.34793 17 3.71872 17 3.58633 16.9023C3.4376 16.7925 3.40126 16.7277 3.38515 16.5436C3.37082 16.3797 3.75646 15.7486 4.52776 14.4866C5.32411 13.1835 6.00031 11.2862 6.00031 8.6C6.00031 7.11479 6.63245 5.69041 7.75766 4.6402C8.88288 3.59 10.409 3 12.0003 3C13.5916 3 15.1177 3.59 16.2429 4.6402C17.3682 5.69041 18.0003 7.11479 18.0003 8.6C18.0003 11.2862 18.6765 13.1835 19.4729 14.4866C20.2441 15.7486 20.6298 16.3797 20.6155 16.5436C20.5994 16.7277 20.563 16.7925 20.4143 16.9023C20.2819 17 19.6527 17 18.3943 17H15.0003M9.00195 17L9.00031 18C9.00031 19.6569 10.3435 21 12.0003 21C13.6572 21 15.0003 19.6569 15.0003 18V17M9.00195 17H15.0003"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
export class NotificationEnabledSvgComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'svg-users',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `
|
||||
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 21V19C16 17.9391 15.5786 16.9217 14.8284 16.1716C14.0783 15.4214 13.0609 15 12 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 11C10.7091 11 12.5 9.20914 12.5 7C12.5 4.79086 10.7091 3 8.5 3C6.29086 3 4.5 4.79086 4.5 7C4.5 9.20914 6.29086 11 8.5 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M23 21V19C22.9993 18.1137 22.7044 17.2525 22.1614 16.5523C21.6184 15.8521 20.8581 15.3516 20 15.13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17 3.13C17.8604 3.35031 18.623 3.85071 19.1676 4.55163C19.7122 5.25254 20.0078 6.11514 20.0078 7.005C20.0078 7.89486 19.7122 8.75746 19.1676 9.45837C18.623 10.1593 17.8604 10.6597 17 10.88" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
export class UsersSvgComponent {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function getServerColor(server: string) {
|
||||
if (server === 'bayou') {
|
||||
return "#0290e8";
|
||||
} else if (server === 'lobby') {
|
||||
return "white";
|
||||
} else if (server === 'creative') {
|
||||
return "#FFA500";
|
||||
}
|
||||
return "#8a9197";
|
||||
}
|
||||
|
||||
export function getServerTextColor(server: string) {
|
||||
if (server === 'bayou') {
|
||||
return "white";
|
||||
} else if (server === 'lobby') {
|
||||
return "black";
|
||||
} else if (server === 'creative') {
|
||||
return "black";
|
||||
}
|
||||
return "white";
|
||||
}
|
||||
@@ -166,7 +166,6 @@
|
||||
<p>/iwanttobreakthisblock, required to break some natural generated blocks</p>
|
||||
<p>/sneakclickmending, allows you to mend your items by right clicking while sneaking</p>
|
||||
<p>Swift Sneak enchantment can be found in villager trades</p>
|
||||
<p>Nether portals can be as small as 1x2 (1x2 portal, 3x4 base)</p>
|
||||
<p>Lootchests refill once for every player</p>
|
||||
<p>Empty maps can be duplicated using paper in a cartography table</p>
|
||||
<p>Eating glow berries gives you a glowing effect</p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<ng-container>
|
||||
<app-header [current_page]="'map'"
|
||||
[background_image]="'public/img/backgrounds/map.jpg'"
|
||||
[overlay_gradient]=0.65>
|
||||
[overlay_gradient]=0.65
|
||||
[height]="'100vh'">
|
||||
<div class="title" header-content>
|
||||
<h1>Dynamic Server Maps</h1>
|
||||
<h2>Which server would you like to see?</h2>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user