Erweiterungen

This commit is contained in:
2025-10-10 12:31:24 +02:00
parent b7e19e7f92
commit b5b25dc04d
10 changed files with 476 additions and 57 deletions

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.controller; package de.assecutor.votianlt.controller;
import de.assecutor.votianlt.model.Message; import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.MessageService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -30,7 +31,7 @@ public class MessageApiController {
/** /**
* Send a general message to a client * Send a general message to a client
* POST /api/messages/send * POST /api/messages/send
* Body: { "content": "message text", "sender": "username", "receiver": "username" } * Body: { "content": "message text", "sender": "username", "receiver": "username", "contentType": "TEXT|IMAGE" }
*/ */
@PostMapping("/send") @PostMapping("/send")
public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) { public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) {
@@ -38,6 +39,7 @@ public class MessageApiController {
String content = request.get("content"); String content = request.get("content");
String sender = request.get("sender"); String sender = request.get("sender");
String receiver = request.get("receiver"); String receiver = request.get("receiver");
MessageContentType contentType = resolveContentType(request.get("contentType"));
if (content == null || content.isBlank() || if (content == null || content.isBlank() ||
sender == null || sender.isBlank() || sender == null || sender.isBlank() ||
@@ -46,10 +48,13 @@ public class MessageApiController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
Message message = messageService.sendGeneralMessageToClient(content, sender, receiver); Message message = messageService.sendGeneralMessageToClient(content, sender, receiver, contentType);
log.info("General message sent from {} to {}", sender, receiver); log.info("General message sent from {} to {}", sender, receiver);
return ResponseEntity.ok(message); return ResponseEntity.ok(message);
} catch (IllegalArgumentException e) {
log.warn("Invalid general message request: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) { } catch (Exception e) {
log.error("Error sending general message: {}", e.getMessage(), e); log.error("Error sending general message: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
@@ -60,7 +65,7 @@ public class MessageApiController {
* Send a job-related message to a client * Send a job-related message to a client
* POST /api/messages/send-job-message * POST /api/messages/send-job-message
* Body: { "content": "message text", "sender": "username", "receiver": "username", * Body: { "content": "message text", "sender": "username", "receiver": "username",
* "jobId": "job id", "jobNumber": "job number" } * "jobId": "job id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" }
*/ */
@PostMapping("/send-job-message") @PostMapping("/send-job-message")
public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) { public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) {
@@ -70,6 +75,7 @@ public class MessageApiController {
String receiver = request.get("receiver"); String receiver = request.get("receiver");
String jobIdStr = request.get("jobId"); String jobIdStr = request.get("jobId");
String jobNumber = request.get("jobNumber"); String jobNumber = request.get("jobNumber");
MessageContentType contentType = resolveContentType(request.get("contentType"));
if (content == null || content.isBlank() || if (content == null || content.isBlank() ||
sender == null || sender.isBlank() || sender == null || sender.isBlank() ||
@@ -80,12 +86,12 @@ public class MessageApiController {
} }
ObjectId jobId = new ObjectId(jobIdStr); ObjectId jobId = new ObjectId(jobIdStr);
Message message = messageService.sendJobMessageToClient(content, sender, receiver, jobId, jobNumber); Message message = messageService.sendJobMessageToClient(content, sender, receiver, contentType, jobId, jobNumber);
log.info("Job-related message sent from {} to {} for job {}", sender, receiver, jobNumber); log.info("Job-related message sent from {} to {} for job {}", sender, receiver, jobNumber);
return ResponseEntity.ok(message); return ResponseEntity.ok(message);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.error("Invalid jobId format: {}", e.getMessage()); log.warn("Invalid job message request: {}", e.getMessage());
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} catch (Exception e) { } catch (Exception e) {
log.error("Error sending job message: {}", e.getMessage(), e); log.error("Error sending job message: {}", e.getMessage(), e);
@@ -228,4 +234,16 @@ public class MessageApiController {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
} }
} }
private MessageContentType resolveContentType(String rawValue) {
if (rawValue == null || rawValue.isBlank()) {
return MessageContentType.TEXT;
}
try {
return MessageContentType.valueOf(rawValue.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Unsupported contentType: " + rawValue, ex);
}
}
} }

View File

@@ -601,7 +601,8 @@ public class MessageController {
* { * {
* "sender": "appUserUsername", * "sender": "appUserUsername",
* "receiver": "systemUserUsername", * "receiver": "systemUserUsername",
* "content": "message text", * "content": "message payload",
* "contentType": "TEXT|IMAGE",
* "jobId": "optional job id", * "jobId": "optional job id",
* "jobNumber": "optional job number" * "jobNumber": "optional job number"
* } * }

View File

@@ -1,13 +1,18 @@
package de.assecutor.votianlt.dto; package de.assecutor.votianlt.dto;
import de.assecutor.votianlt.model.MessageContentType;
import java.util.Map; import java.util.Map;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
/** /**
* Normalized payload for chat messages sent by mobile clients via MQTT. * Normalized payload for chat messages sent by mobile clients via MQTT.
*/ */
public record ChatMessageInboundPayload(String sender, String receiver, String content, ObjectId jobId, String jobNumber) { public record ChatMessageInboundPayload(String sender, String receiver, String content,
MessageContentType contentType, ObjectId jobId, String jobNumber) {
public ChatMessageInboundPayload {
contentType = contentType != null ? contentType : MessageContentType.TEXT;
}
public static ChatMessageInboundPayload fromPayload(Map<String, Object> payload) { public static ChatMessageInboundPayload fromPayload(Map<String, Object> payload) {
if (payload == null) { if (payload == null) {
@@ -17,10 +22,11 @@ public record ChatMessageInboundPayload(String sender, String receiver, String c
String sender = extractRequiredString(payload, "sender"); String sender = extractRequiredString(payload, "sender");
String receiver = extractRequiredString(payload, "receiver"); String receiver = extractRequiredString(payload, "receiver");
String content = extractRequiredString(payload, "content"); String content = extractRequiredString(payload, "content");
MessageContentType contentType = extractContentType(payload.get("contentType"));
ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId"); ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId");
String jobNumber = extractOptionalString(payload.get("jobNumber")); String jobNumber = extractOptionalString(payload.get("jobNumber"));
return new ChatMessageInboundPayload(sender, receiver, content, jobId, jobNumber); return new ChatMessageInboundPayload(sender, receiver, content, contentType, jobId, jobNumber);
} }
public boolean hasJobContext() { public boolean hasJobContext() {
@@ -56,4 +62,16 @@ public record ChatMessageInboundPayload(String sender, String receiver, String c
"Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex); "Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex);
} }
} }
private static MessageContentType extractContentType(Object value) {
String candidate = extractOptionalString(value);
if (candidate == null) {
return MessageContentType.TEXT;
}
try {
return MessageContentType.valueOf(candidate.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Unsupported contentType '%s'".formatted(candidate), ex);
}
}
} }

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.dto; package de.assecutor.votianlt.dto;
import de.assecutor.votianlt.model.Message; import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.model.MessageType;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -13,6 +14,7 @@ public record ChatMessageOutboundPayload(
String sender, String sender,
String receiver, String receiver,
String content, String content,
MessageContentType contentType,
MessageOrigin origin, MessageOrigin origin,
MessageType messageType, MessageType messageType,
LocalDateTime createdAt, LocalDateTime createdAt,
@@ -27,6 +29,7 @@ public record ChatMessageOutboundPayload(
message.getSender(), message.getSender(),
message.getReceiver(), message.getReceiver(),
message.getContent(), message.getContent(),
message.getContentType(),
message.getOrigin(), message.getOrigin(),
message.getMessageType(), message.getMessageType(),
message.getCreatedAt(), message.getCreatedAt(),

View File

@@ -25,11 +25,17 @@ public class Message {
private ObjectId id; private ObjectId id;
/** /**
* Content of the message * Content of the message, either plain text or base64 encoded media
*/ */
@Field("content") @Field("content")
private String content; private String content;
/**
* Declares how to interpret the content payload
*/
@Field("content_type")
private MessageContentType contentType = MessageContentType.TEXT;
/** /**
* Username of the sender (app user or system user) * Username of the sender (app user or system user)
*/ */
@@ -88,29 +94,35 @@ public class Message {
* Constructor for general messages * Constructor for general messages
*/ */
public Message(String content, String sender, String receiver, MessageOrigin origin) { public Message(String content, String sender, String receiver, MessageOrigin origin) {
this.content = content; this(content, sender, receiver, origin, MessageContentType.TEXT);
this.sender = sender; }
this.receiver = receiver;
this.origin = origin; /**
* Constructor for general messages with explicit content type
*/
public Message(String content, String sender, String receiver, MessageOrigin origin,
MessageContentType contentType) {
initializeBaseFields(content, sender, receiver, origin, contentType);
this.messageType = MessageType.GENERAL; this.messageType = MessageType.GENERAL;
this.createdAt = LocalDateTime.now();
this.isRead = false;
} }
/** /**
* Constructor for job-related messages * Constructor for job-related messages
*/ */
public Message(String content, String sender, String receiver, MessageOrigin origin, public Message(String content, String sender, String receiver, MessageOrigin origin,
ObjectId jobId, String jobNumber) { ObjectId jobId, String jobNumber) {
this.content = content; this(content, sender, receiver, origin, MessageContentType.TEXT, jobId, jobNumber);
this.sender = sender; }
this.receiver = receiver;
this.origin = origin; /**
* Constructor for job-related messages with explicit content type
*/
public Message(String content, String sender, String receiver, MessageOrigin origin,
MessageContentType contentType, ObjectId jobId, String jobNumber) {
initializeBaseFields(content, sender, receiver, origin, contentType);
this.messageType = MessageType.JOB_RELATED; this.messageType = MessageType.JOB_RELATED;
this.jobId = jobId; this.jobId = jobId;
this.jobNumber = jobNumber; this.jobNumber = jobNumber;
this.createdAt = LocalDateTime.now();
this.isRead = false;
} }
/** /**
@@ -136,4 +148,22 @@ public class Message {
public String getJobIdAsString() { public String getJobIdAsString() {
return jobId != null ? jobId.toString() : null; return jobId != null ? jobId.toString() : null;
} }
/**
* Ensure callers always receive a non-null content type to simplify rendering
*/
public MessageContentType getContentType() {
return contentType != null ? contentType : MessageContentType.TEXT;
}
private void initializeBaseFields(String content, String sender, String receiver, MessageOrigin origin,
MessageContentType contentType) {
this.content = content;
this.sender = sender;
this.receiver = receiver;
this.origin = origin;
this.createdAt = LocalDateTime.now();
this.isRead = false;
this.contentType = contentType != null ? contentType : MessageContentType.TEXT;
}
} }

View File

@@ -0,0 +1,9 @@
package de.assecutor.votianlt.model;
/**
* Supported content variants for chat messages.
*/
public enum MessageContentType {
TEXT,
IMAGE
}

View File

@@ -1,20 +1,27 @@
package de.assecutor.votianlt.pages.view; package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H2; import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.html.Main; import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.Scroller; import com.vaadin.flow.component.orderedlayout.Scroller;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.UploadI18N;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
@@ -23,6 +30,7 @@ import com.vaadin.flow.router.RouteParameters;
import com.vaadin.flow.shared.Registration; import com.vaadin.flow.shared.Registration;
import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Message; import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.model.MessageType;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
@@ -33,13 +41,17 @@ import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
@Route(value = "message-details/:clientId/:conversationId", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @Route(value = "message-details/:clientId/:conversationId", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@PageTitle("Nachrichtenverlauf") @PageTitle("Nachrichtenverlauf")
@@ -63,8 +75,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private Div scrollAnchor; // Anchor element for scrolling to bottom private Div scrollAnchor; // Anchor element for scrolling to bottom
private List<Message> currentMessages; // Current messages being displayed for redrawing private List<Message> currentMessages; // Current messages being displayed for redrawing
private Registration broadcasterRegistration; // Track listener registration private Registration broadcasterRegistration; // Track listener registration
private TextArea messageInput;
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final int MAX_IMAGE_FILE_SIZE = 32 * 1024 * 1024; // 32 MB aligns with Spring settings
public MessageDetailsView(AppUserService appUserService, MessageService messageService, public MessageDetailsView(AppUserService appUserService, MessageService messageService,
SecurityService securityService, MessageBroadcaster messageBroadcaster) { SecurityService securityService, MessageBroadcaster messageBroadcaster) {
@@ -196,6 +210,141 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
} }
} }
private void openImageUploadDialog() {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Bild anhängen");
dialog.setCloseOnEsc(true);
dialog.setCloseOnOutsideClick(true);
dialog.setWidth("480px");
MemoryBuffer buffer = new MemoryBuffer();
Upload upload = new Upload(buffer);
upload.setAcceptedFileTypes("image/png", "image/jpeg", "image/gif", "image/webp");
upload.setMaxFiles(1);
upload.setMaxFileSize(MAX_IMAGE_FILE_SIZE);
upload.setI18n(createGermanUploadI18n());
upload.setWidthFull();
Span helper = new Span("Unterstützte Formate: PNG, JPG, GIF, WebP (max. 32 MB)");
helper.getStyle()
.set("font-size", "12px")
.set("color", "#666666");
Image preview = new Image();
preview.setAlt("Vorschau des ausgewählten Bildes");
preview.setVisible(false);
preview.setWidth(null);
preview.setHeight(null);
preview.getStyle()
.set("max-width", "100%")
.set("max-height", "320px")
.set("height", "auto")
.set("border-radius", "12px")
.set("display", "inline-block");
Div previewWrapper = new Div(preview);
previewWrapper.setWidthFull();
previewWrapper.getStyle()
.set("text-align", "center")
.set("margin-top", "10px");
AtomicReference<String> base64Ref = new AtomicReference<>();
Button confirmButton = new Button("Senden");
confirmButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY);
confirmButton.setEnabled(false);
Button cancelButton = new Button("Abbrechen", event -> dialog.close());
upload.addSucceededListener(event -> {
try (InputStream inputStream = buffer.getInputStream()) {
byte[] bytes = inputStream.readAllBytes();
if (bytes.length == 0) {
base64Ref.set(null);
preview.setVisible(false);
confirmButton.setEnabled(false);
Notification.show("Die Bilddatei ist leer.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
String base64 = Base64.getEncoder().encodeToString(bytes);
base64Ref.set(base64);
String mimeType = Optional.ofNullable(event.getMIMEType()).filter(value -> !value.isBlank())
.orElseGet(() -> detectImageMimeType(bytes));
String dataUrl = createDataUrlFromContent(base64, mimeType);
if (dataUrl == null) {
preview.setVisible(false);
confirmButton.setEnabled(false);
Notification.show("Das Bild konnte nicht verarbeitet werden.", 3000,
Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
preview.setSrc(dataUrl);
preview.setVisible(true);
confirmButton.setEnabled(true);
} catch (IOException ex) {
log.error("Fehler beim Verarbeiten der Bilddatei", ex);
base64Ref.set(null);
preview.setVisible(false);
confirmButton.setEnabled(false);
Notification.show("Das Bild konnte nicht gelesen werden.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
upload.addFileRejectedListener(event -> {
base64Ref.set(null);
preview.setVisible(false);
confirmButton.setEnabled(false);
Notification.show(event.getErrorMessage(), 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
});
upload.addFailedListener(event -> {
base64Ref.set(null);
preview.setVisible(false);
confirmButton.setEnabled(false);
Notification.show("Der Upload ist fehlgeschlagen.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
});
upload.addFileRemovedListener(event -> {
base64Ref.set(null);
preview.setVisible(false);
confirmButton.setEnabled(false);
});
confirmButton.addClickListener(event -> {
String base64 = base64Ref.get();
if (base64 == null || base64.isBlank()) {
Notification.show("Bitte zuerst ein Bild auswählen.", 2500, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
dialog.close();
sendMessageToParticipant(base64, MessageContentType.IMAGE);
});
VerticalLayout dialogContent = new VerticalLayout(upload, helper, previewWrapper);
dialogContent.setSpacing(true);
dialogContent.setPadding(false);
dialogContent.setAlignItems(FlexComponent.Alignment.STRETCH);
dialogContent.setWidthFull();
HorizontalLayout buttonBar = new HorizontalLayout(cancelButton, confirmButton);
buttonBar.setWidthFull();
buttonBar.setSpacing(true);
buttonBar.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
dialog.add(dialogContent);
dialog.getFooter().add(buttonBar);
dialog.open();
}
/** /**
* Create a date separator component * Create a date separator component
*/ */
@@ -252,15 +401,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("white-space", "pre-wrap") .set("white-space", "pre-wrap")
.set("text-align", alignment); .set("text-align", alignment);
// Message content // Message content component (text or media)
Div contentDiv = new Div(); Component contentComponent = createContentComponent(message, alignment);
String content = Optional.ofNullable(message.getContent()).orElse("(kein Inhalt)");
contentDiv.setText(content);
contentDiv.getStyle()
.set("font-size", "14px")
.set("color", "#000000")
.set("margin-bottom", "5px")
.set("text-align", alignment);
// Timestamp // Timestamp
Span timeSpan = new Span(timestamp.format(TIME_FORMATTER)); Span timeSpan = new Span(timestamp.format(TIME_FORMATTER));
@@ -270,12 +412,147 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("display", "block") .set("display", "block")
.set("text-align", alignment); .set("text-align", alignment);
bubble.add(contentDiv, timeSpan); bubble.add(contentComponent, timeSpan);
messageWrapper.add(bubble); messageWrapper.add(bubble);
return messageWrapper; return messageWrapper;
} }
private Component createContentComponent(Message message, String alignment) {
MessageContentType contentType = message.getContentType();
return switch (contentType) {
case IMAGE -> createImageContent(message.getContent(), alignment);
case TEXT -> createTextContent(message.getContent(), alignment);
};
}
private Component createTextContent(String contentValue, String alignment) {
Div contentDiv = new Div();
String content = Optional.ofNullable(contentValue).filter(value -> !value.isBlank())
.orElse("(kein Inhalt)");
contentDiv.setText(content);
contentDiv.getStyle()
.set("font-size", "14px")
.set("color", "#000000")
.set("margin-bottom", "5px")
.set("text-align", alignment);
return contentDiv;
}
private Component createImageContent(String base64Value, String alignment) {
Div wrapper = new Div();
wrapper.getStyle()
.set("margin-bottom", "5px")
.set("display", "flex")
.set("justify-content", "right".equals(alignment) ? "flex-end" : "flex-start");
String trimmed = Optional.ofNullable(base64Value).map(String::trim).orElse("");
if (trimmed.isEmpty()) {
wrapper.setText("(kein Bildinhalt)");
return wrapper;
}
String dataUrl = createDataUrlFromContent(trimmed, null);
if (dataUrl == null) {
wrapper.setText("(Bild konnte nicht geladen werden)");
return wrapper;
}
Image image = new Image(dataUrl, "Nachrichtenbild");
image.getStyle()
.set("max-width", "100%")
.set("border-radius", "12px")
.set("display", "block")
.set("max-height", "320px")
.set("height", "auto");
image.getElement().setAttribute("loading", "lazy");
wrapper.add(image);
return wrapper;
}
private String createDataUrlFromContent(String contentValue, String mimeTypeHint) {
if (contentValue == null || contentValue.isBlank()) {
return null;
}
String trimmed = contentValue.trim();
if (trimmed.startsWith("data:")) {
return trimmed;
}
byte[] imageBytes;
try {
imageBytes = Base64.getDecoder().decode(trimmed);
} catch (IllegalArgumentException ex) {
log.warn("Ungültiger Base64-Inhalt für Bildnachricht", ex);
return null;
}
String mimeType = Optional.ofNullable(mimeTypeHint).filter(value -> !value.isBlank())
.orElseGet(() -> detectImageMimeType(imageBytes));
return "data:" + mimeType + ";base64," + trimmed;
}
private String detectImageMimeType(byte[] bytes) {
if (bytes == null || bytes.length < 4) {
return "image/png";
}
if ((bytes[0] & 0xFF) == 0xFF && (bytes[1] & 0xFF) == 0xD8) {
return "image/jpeg";
}
if ((bytes[0] & 0xFF) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) {
return "image/png";
}
if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38) {
return "image/gif";
}
if (bytes.length >= 12 && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46
&& bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) {
return "image/webp";
}
if (bytes[0] == 0x42 && bytes[1] == 0x4D) {
return "image/bmp";
}
return "image/png";
}
private UploadI18N createGermanUploadI18n() {
UploadI18N i18n = new UploadI18N();
UploadI18N.AddFiles addFiles = new UploadI18N.AddFiles();
addFiles.setOne("Bild auswählen");
addFiles.setMany("Bilder auswählen");
i18n.setAddFiles(addFiles);
UploadI18N.DropFiles dropFiles = new UploadI18N.DropFiles();
dropFiles.setOne("Bild hierher ziehen");
dropFiles.setMany("Bilder hierher ziehen");
i18n.setDropFiles(dropFiles);
UploadI18N.Error error = new UploadI18N.Error();
error.setFileIsTooBig("Die Datei ist größer als 32 MB.");
error.setTooManyFiles("Es kann nur ein Bild gleichzeitig hochgeladen werden.");
i18n.setError(error);
UploadI18N.Uploading uploading = new UploadI18N.Uploading();
uploading.setStatus(new UploadI18N.Uploading.Status()
.setConnecting("Verbindung wird aufgebaut...")
.setStalled("Upload pausiert")
.setProcessing("Verarbeitung...")
.setHeld("Warten auf Upload..."));
uploading.setRemainingTime(new UploadI18N.Uploading.RemainingTime()
.setPrefix("Verbleibende Zeit: ")
.setUnknown("Verbleibende Zeit unbekannt"));
uploading.setError(new UploadI18N.Uploading.Error()
.setServerUnavailable("Server nicht erreichbar")
.setUnexpectedServerError("Unerwarteter Serverfehler")
.setForbidden("Upload nicht erlaubt"));
i18n.setUploading(uploading);
return i18n;
}
/** /**
* Ensure scroll anchor exists at the bottom of messages container * Ensure scroll anchor exists at the bottom of messages container
*/ */
@@ -326,12 +603,20 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private HorizontalLayout createMessageInputArea() { private HorizontalLayout createMessageInputArea() {
TextArea messageInput = new TextArea(); messageInput = new TextArea();
messageInput.setPlaceholder("Nachricht eingeben..."); messageInput.setPlaceholder("Nachricht eingeben...");
messageInput.setWidthFull(); messageInput.setWidthFull();
messageInput.getStyle().set("min-height", "60px"); messageInput.getStyle().set("min-height", "60px");
messageInput.getStyle().set("max-height", "120px"); messageInput.getStyle().set("max-height", "120px");
Button attachButton = new Button(VaadinIcon.PAPERCLIP.create());
attachButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_TERTIARY,
com.vaadin.flow.component.button.ButtonVariant.LUMO_ICON);
attachButton.getStyle().set("height", "60px");
attachButton.getElement().setProperty("title", "Bild anhängen");
attachButton.getElement().setAttribute("aria-label", "Bild anhängen");
attachButton.addClickListener(event -> openImageUploadDialog());
Button sendButton = new Button("Senden", VaadinIcon.PAPERPLANE.create()); Button sendButton = new Button("Senden", VaadinIcon.PAPERPLANE.create());
sendButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY); sendButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY);
sendButton.getStyle().set("height", "60px"); sendButton.getStyle().set("height", "60px");
@@ -339,30 +624,43 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
sendButton.addClickListener(e -> { sendButton.addClickListener(e -> {
String message = messageInput.getValue(); String message = messageInput.getValue();
if (message != null && !message.trim().isEmpty()) { if (message != null && !message.trim().isEmpty()) {
sendMessageToParticipant(message.trim()); sendMessageToParticipant(message, MessageContentType.TEXT);
messageInput.clear(); messageInput.clear();
} }
}); });
HorizontalLayout layout = new HorizontalLayout(messageInput, sendButton); HorizontalLayout layout = new HorizontalLayout(attachButton, messageInput, sendButton);
layout.setWidthFull(); layout.setWidthFull();
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.END); layout.setAlignItems(FlexComponent.Alignment.END);
layout.setSpacing(true);
layout.expand(messageInput); layout.expand(messageInput);
return layout; return layout;
} }
private void sendMessageToParticipant(String content) { private void sendMessageToParticipant(String content, MessageContentType contentType) {
String payload = Optional.ofNullable(content).orElse("");
if (contentType == MessageContentType.TEXT) {
payload = payload.trim();
}
if (payload.isEmpty()) {
Notification.show("Nachricht enthält keinen Inhalt.", 2500, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
String sender = Optional.ofNullable(securityService.getCurrentUsername()).filter(name -> !name.isBlank()) String sender = Optional.ofNullable(securityService.getCurrentUsername()).filter(name -> !name.isBlank())
.orElse("System"); .orElse("System");
try { try {
Message saved; Message saved;
if (jobConversation) { if (jobConversation) {
saved = messageService.sendJobMessageToClient(content, sender, participantKey, jobIdContext, saved = messageService.sendJobMessageToClient(payload, sender, participantKey,
jobNumberContext); contentType, jobIdContext, jobNumberContext);
} else { } else {
saved = messageService.sendGeneralMessageToClient(content, sender, participantKey); saved = messageService.sendGeneralMessageToClient(payload, sender, participantKey,
contentType);
} }
Notification.show("Nachricht gesendet", 2000, Notification.Position.BOTTOM_END) Notification.show("Nachricht gesendet", 2000, Notification.Position.BOTTOM_END)

View File

@@ -18,6 +18,7 @@ import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.dto.ClientMessageSummary; import de.assecutor.votianlt.dto.ClientMessageSummary;
import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Message; import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.MessageService;
@@ -207,7 +208,7 @@ public class MessagesView extends Main {
.orElse(conversation.get(0)); .orElse(conversation.get(0));
LocalDateTime lastDate = latest.getCreatedAt(); LocalDateTime lastDate = latest.getCreatedAt();
String preview = Optional.ofNullable(latest.getContent()).filter(s -> !s.isBlank()).orElse("(kein Inhalt)"); String preview = resolvePreview(latest);
int totalMessages = conversation.size(); int totalMessages = conversation.size();
int unreadCount = (int) conversation.stream() int unreadCount = (int) conversation.stream()
.filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead()) .filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead())
@@ -239,6 +240,20 @@ public class MessagesView extends Main {
return summaries; return summaries;
} }
private String resolvePreview(Message message) {
if (message == null) {
return "(kein Inhalt)";
}
if (message.getContentType() == MessageContentType.IMAGE) {
return "[Bildnachricht]";
}
return Optional.ofNullable(message.getContent())
.filter(content -> !content.isBlank())
.orElse("(kein Inhalt)");
}
private String resolveParticipantKey(Message message) { private String resolveParticipantKey(Message message) {
if (message == null) { if (message == null) {
return null; return null;

View File

@@ -16,6 +16,7 @@ import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Message; import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.model.MessageType;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
@@ -134,7 +135,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
.count(); .count();
int messageCount = sortedMessages.size(); int messageCount = sortedMessages.size();
LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null; LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null;
String preview = latest != null ? latest.getContent() : null; String preview = resolvePreview(latest);
section.add(createMessageCard( section.add(createMessageCard(
"Allgemeine Unterhaltung", "Allgemeine Unterhaltung",
@@ -178,7 +179,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
String conversationTitle = "Auftrag " + jobKey; String conversationTitle = "Auftrag " + jobKey;
section.add(createMessageCard( section.add(createMessageCard(
conversationTitle, conversationTitle,
Optional.ofNullable(latest.getContent()).orElse(""), resolvePreview(latest),
latest.getCreatedAt(), latest.getCreatedAt(),
messages.size(), messages.size(),
unreadCount, unreadCount,
@@ -189,6 +190,20 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
return section; return section;
} }
private String resolvePreview(Message message) {
if (message == null) {
return "";
}
if (message.getContentType() == MessageContentType.IMAGE) {
return "[Bildnachricht]";
}
return Optional.ofNullable(message.getContent())
.map(String::trim)
.orElse("");
}
private Div createMessageCard(String conversationTitle, String lastMessagePreview, private Div createMessageCard(String conversationTitle, String lastMessagePreview,
LocalDateTime lastMessageTime, int messageCount, LocalDateTime lastMessageTime, int messageCount,
int unreadCount, String conversationId) { int unreadCount, String conversationId) {

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.service; package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.Message; import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.model.MessageType;
import de.assecutor.votianlt.dto.ChatMessageInboundPayload; import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
@@ -44,7 +45,12 @@ public class MessageService {
* Send a general message to a client via MQTT * Send a general message to a client via MQTT
*/ */
public Message sendGeneralMessageToClient(String content, String sender, String receiver) { public Message sendGeneralMessageToClient(String content, String sender, String receiver) {
Message message = new Message(content, sender, receiver, MessageOrigin.SERVER); return sendGeneralMessageToClient(content, sender, receiver, MessageContentType.TEXT);
}
public Message sendGeneralMessageToClient(String content, String sender, String receiver,
MessageContentType contentType) {
Message message = new Message(content, sender, receiver, MessageOrigin.SERVER, contentType);
message = saveMessage(message); message = saveMessage(message);
publishMessageToMqtt(message, receiver); publishMessageToMqtt(message, receiver);
return message; return message;
@@ -54,8 +60,13 @@ public class MessageService {
* Send a job-related message to a client via MQTT * Send a job-related message to a client via MQTT
*/ */
public Message sendJobMessageToClient(String content, String sender, String receiver, public Message sendJobMessageToClient(String content, String sender, String receiver,
ObjectId jobId, String jobNumber) { ObjectId jobId, String jobNumber) {
Message message = new Message(content, sender, receiver, MessageOrigin.SERVER, jobId, jobNumber); return sendJobMessageToClient(content, sender, receiver, MessageContentType.TEXT, jobId, jobNumber);
}
public Message sendJobMessageToClient(String content, String sender, String receiver,
MessageContentType contentType, ObjectId jobId, String jobNumber) {
Message message = new Message(content, sender, receiver, MessageOrigin.SERVER, contentType, jobId, jobNumber);
message = saveMessage(message); message = saveMessage(message);
publishMessageToMqtt(message, receiver); publishMessageToMqtt(message, receiver);
return message; return message;
@@ -66,12 +77,13 @@ public class MessageService {
*/ */
public Message receiveMessageFromClient(ChatMessageInboundPayload payload) { public Message receiveMessageFromClient(ChatMessageInboundPayload payload) {
Message message; Message message;
MessageContentType contentType = payload.contentType();
if (payload.hasJobContext()) { if (payload.hasJobContext()) {
message = new Message(payload.content(), payload.sender(), payload.receiver(), message = new Message(payload.content(), payload.sender(), payload.receiver(),
MessageOrigin.CLIENT, payload.jobId(), payload.jobNumber()); MessageOrigin.CLIENT, contentType, payload.jobId(), payload.jobNumber());
} else { } else {
message = new Message(payload.content(), payload.sender(), payload.receiver(), message = new Message(payload.content(), payload.sender(), payload.receiver(),
MessageOrigin.CLIENT); MessageOrigin.CLIENT, contentType);
} }
message = saveMessage(message); message = saveMessage(message);