From 065db06e16d93f742b1705d051284758aead6b76 Mon Sep 17 00:00:00 2001 From: akastijn Date: Sun, 2 Aug 2026 18:30:33 +0200 Subject: [PATCH] Add chat input functionality in `ChatBoxComponent` and backend support for sending server messages --- .../altitudeweb/config/SecurityConfig.java | 1 + .../chat/ServerMessageController.java | 46 ++++++++++++++++ .../components/chat/chat-box.component.html | 12 +++++ .../components/chat/chat-box.component.scss | 54 ++++++++++++++++++- .../components/chat/chat-box.component.ts | 50 ++++++++++++++++- open_api/src/main/resources/api.yml | 4 ++ .../schemas/server-message/server-message.yml | 44 +++++++++++++++ 7 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ServerMessageController.java create mode 100644 open_api/src/main/resources/schemas/server-message/server-message.yml diff --git a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java index 5781c43..ae66826 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java +++ b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java @@ -71,6 +71,7 @@ public class SecurityConfig { .requestMatchers("/api/files/download/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/login/userLogin/**").permitAll() + .requestMatchers("/api/site/chat/**").authenticated() .requestMatchers("/api/chat/server/send/**").permitAll() .anyRequest().permitAll() ) diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ServerMessageController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ServerMessageController.java new file mode 100644 index 0000000..2f35041 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ServerMessageController.java @@ -0,0 +1,46 @@ +package com.alttd.altitudeweb.controllers.chat; + +import com.alttd.altitudeweb.api.ServerMessageApi; +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.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; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class ServerMessageController implements ServerMessageApi { + + private final ServerMessageService serverMessageService; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + 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 sendServerMessage(String server, ServerMessageRequestDto serverMessageRequestDto) { + ChatFromWeb chatFromWeb = ChatFromWeb.builder() + .sender(serverMessageRequestDto.getUuid()) + .message(serverMessageRequestDto.getMessage()) + .build(); + + String json = toJson(chatFromWeb); + + if (serverMessageService.sendMessage(server, "web_chat", json)) { + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } +} diff --git a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.html b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.html index 8791661..ea86efd 100644 --- a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.html +++ b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.html @@ -12,3 +12,15 @@

} + +@if (canChat()) { +
+ + +
+} diff --git a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.scss b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.scss index 8a101a1..2f96baa 100644 --- a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.scss +++ b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.scss @@ -1,12 +1,14 @@ :host { - display: block; + display: flex; + flex-direction: column; height: 100%; } .message-container { + flex: 1 1 auto; display: flex; flex-direction: column-reverse; - height: 100%; + min-height: 0; overflow-y: auto; box-sizing: border-box; scrollbar-width: thin; @@ -41,3 +43,51 @@ .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; + } + } +} diff --git a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.ts b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.ts index 24a7c6a..c81e85e 100644 --- a/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.ts +++ b/frontend/src/app/pages/altitude/chat/components/chat/chat-box.component.ts @@ -1,17 +1,63 @@ -import {Component, input} from '@angular/core'; +import {Component, computed, inject, input} from '@angular/core'; import {DatePipe} from '@angular/common'; import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object'; import {MiniMessageComponent} from '@pages/altitude/chat/components/mini-message/mini-message.component'; +import {AuthService} from '@services/auth.service'; +import {ChatService} from '@pages/altitude/chat/service/chat.service'; +import {ServerMessageService} from '@api'; +import {FormsModule} from '@angular/forms'; +import {MatSnackBar} from '@angular/material/snack-bar'; @Component({ selector: 'app-chat-box', + standalone: true, imports: [ MiniMessageComponent, - DatePipe + DatePipe, + FormsModule ], templateUrl: './chat-box.component.html', styleUrl: './chat-box.component.scss' }) export class ChatBoxComponent { + private readonly authService = inject(AuthService); + private readonly chatService = inject(ChatService); + private readonly serverMessageService = inject(ServerMessageService); + private readonly matSnackBar = inject(MatSnackBar); + readonly messages = input.required(); + chatText = ''; + + readonly canChat = computed(() => { + const hasPrivilege = this.authService.hasAccess(['SCOPE_head_mod']); + const isServerChannel = this.chatService.selectedChannel()?.type === 'SERVER'; + if (!hasPrivilege || !isServerChannel) { + console.log(`User has privilege ${hasPrivilege}, user in server channel ${isServerChannel}`); + } + return hasPrivilege && isServerChannel; + }); + + sendMessage() { + const message = this.chatText.trim(); + if (!message) 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 + }); + } + }); + } + } } diff --git a/open_api/src/main/resources/api.yml b/open_api/src/main/resources/api.yml index 14c9b80..048a3b7 100644 --- a/open_api/src/main/resources/api.yml +++ b/open_api/src/main/resources/api.yml @@ -32,6 +32,8 @@ tags: description: Actions related to small features on the site such as displaying vote stats or pt/rank stats - name: chatInfo description: All actions related to chat information + - name: server-message + description: All actions related to sending messages to servers paths: /api/team/{team}: $ref: './schemas/team/team.yml#/getTeam' @@ -103,3 +105,5 @@ paths: $ref: './schemas/chat_info/chat_info.yml#/PartyName' /api/site/chat/player/{uuid}/name: $ref: './schemas/chat_info/chat_info.yml#/UserName' + /api/site/chat/send-message/{server}: + $ref: './schemas/server-message/server-message.yml#/sendServerMessage' diff --git a/open_api/src/main/resources/schemas/server-message/server-message.yml b/open_api/src/main/resources/schemas/server-message/server-message.yml new file mode 100644 index 0000000..e1f733f --- /dev/null +++ b/open_api/src/main/resources/schemas/server-message/server-message.yml @@ -0,0 +1,44 @@ +sendServerMessage: + post: + tags: + - server-message + summary: Send a message to a specific server + operationId: sendServerMessage + parameters: + - $ref: '#/components/parameters/Server' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ServerMessageRequest' + responses: + '202': + description: Message accepted and sent + '404': + description: Server not found or not connected + '400': + description: Invalid request +components: + parameters: + Server: + name: server + in: path + required: true + schema: + type: string + description: The name of the server + schemas: + ServerMessageRequest: + type: object + required: + - uuid + - message + properties: + uuid: + type: string + format: uuid + description: The sender UUID + message: + type: string + description: The message to send