Erweiterungen
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package de.assecutor.votianlt.controller;
|
||||
|
||||
import de.assecutor.votianlt.model.Message;
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.service.MessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -30,7 +31,7 @@ public class MessageApiController {
|
||||
/**
|
||||
* Send a general message to a client
|
||||
* 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")
|
||||
public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) {
|
||||
@@ -38,6 +39,7 @@ public class MessageApiController {
|
||||
String content = request.get("content");
|
||||
String sender = request.get("sender");
|
||||
String receiver = request.get("receiver");
|
||||
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
||||
|
||||
if (content == null || content.isBlank() ||
|
||||
sender == null || sender.isBlank() ||
|
||||
@@ -46,10 +48,13 @@ public class MessageApiController {
|
||||
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);
|
||||
return ResponseEntity.ok(message);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid general message request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error sending general message: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
@@ -60,7 +65,7 @@ public class MessageApiController {
|
||||
* Send a job-related message to a client
|
||||
* POST /api/messages/send-job-message
|
||||
* 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")
|
||||
public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) {
|
||||
@@ -70,6 +75,7 @@ public class MessageApiController {
|
||||
String receiver = request.get("receiver");
|
||||
String jobIdStr = request.get("jobId");
|
||||
String jobNumber = request.get("jobNumber");
|
||||
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
||||
|
||||
if (content == null || content.isBlank() ||
|
||||
sender == null || sender.isBlank() ||
|
||||
@@ -80,12 +86,12 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
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);
|
||||
return ResponseEntity.ok(message);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid jobId format: {}", e.getMessage());
|
||||
log.warn("Invalid job message request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception 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();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,7 +601,8 @@ public class MessageController {
|
||||
* {
|
||||
* "sender": "appUserUsername",
|
||||
* "receiver": "systemUserUsername",
|
||||
* "content": "message text",
|
||||
* "content": "message payload",
|
||||
* "contentType": "TEXT|IMAGE",
|
||||
* "jobId": "optional job id",
|
||||
* "jobNumber": "optional job number"
|
||||
* }
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package de.assecutor.votianlt.dto;
|
||||
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import java.util.Map;
|
||||
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (payload == null) {
|
||||
@@ -17,10 +22,11 @@ public record ChatMessageInboundPayload(String sender, String receiver, String c
|
||||
String sender = extractRequiredString(payload, "sender");
|
||||
String receiver = extractRequiredString(payload, "receiver");
|
||||
String content = extractRequiredString(payload, "content");
|
||||
MessageContentType contentType = extractContentType(payload.get("contentType"));
|
||||
ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId");
|
||||
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() {
|
||||
@@ -56,4 +62,16 @@ public record ChatMessageInboundPayload(String sender, String receiver, String c
|
||||
"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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.assecutor.votianlt.dto;
|
||||
|
||||
import de.assecutor.votianlt.model.Message;
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.model.MessageType;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -13,6 +14,7 @@ public record ChatMessageOutboundPayload(
|
||||
String sender,
|
||||
String receiver,
|
||||
String content,
|
||||
MessageContentType contentType,
|
||||
MessageOrigin origin,
|
||||
MessageType messageType,
|
||||
LocalDateTime createdAt,
|
||||
@@ -27,6 +29,7 @@ public record ChatMessageOutboundPayload(
|
||||
message.getSender(),
|
||||
message.getReceiver(),
|
||||
message.getContent(),
|
||||
message.getContentType(),
|
||||
message.getOrigin(),
|
||||
message.getMessageType(),
|
||||
message.getCreatedAt(),
|
||||
|
||||
@@ -25,11 +25,17 @@ public class Message {
|
||||
private ObjectId id;
|
||||
|
||||
/**
|
||||
* Content of the message
|
||||
* Content of the message, either plain text or base64 encoded media
|
||||
*/
|
||||
@Field("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)
|
||||
*/
|
||||
@@ -88,13 +94,16 @@ public class Message {
|
||||
* Constructor for general messages
|
||||
*/
|
||||
public Message(String content, String sender, String receiver, MessageOrigin origin) {
|
||||
this.content = content;
|
||||
this.sender = sender;
|
||||
this.receiver = receiver;
|
||||
this.origin = origin;
|
||||
this(content, sender, receiver, origin, MessageContentType.TEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.createdAt = LocalDateTime.now();
|
||||
this.isRead = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,15 +111,18 @@ public class Message {
|
||||
*/
|
||||
public Message(String content, String sender, String receiver, MessageOrigin origin,
|
||||
ObjectId jobId, String jobNumber) {
|
||||
this.content = content;
|
||||
this.sender = sender;
|
||||
this.receiver = receiver;
|
||||
this.origin = origin;
|
||||
this(content, sender, receiver, origin, MessageContentType.TEXT, jobId, jobNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.jobId = jobId;
|
||||
this.jobNumber = jobNumber;
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.isRead = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,4 +148,22 @@ public class Message {
|
||||
public String getJobIdAsString() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
/**
|
||||
* Supported content variants for chat messages.
|
||||
*/
|
||||
public enum MessageContentType {
|
||||
TEXT,
|
||||
IMAGE
|
||||
}
|
||||
@@ -1,20 +1,27 @@
|
||||
package de.assecutor.votianlt.pages.view;
|
||||
|
||||
import com.vaadin.flow.component.AttachEvent;
|
||||
import com.vaadin.flow.component.Component;
|
||||
import com.vaadin.flow.component.DetachEvent;
|
||||
import com.vaadin.flow.component.UI;
|
||||
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.H2;
|
||||
import com.vaadin.flow.component.html.Image;
|
||||
import com.vaadin.flow.component.html.Main;
|
||||
import com.vaadin.flow.component.html.Span;
|
||||
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.Scroller;
|
||||
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.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.BeforeEnterObserver;
|
||||
import com.vaadin.flow.router.PageTitle;
|
||||
@@ -23,6 +30,7 @@ import com.vaadin.flow.router.RouteParameters;
|
||||
import com.vaadin.flow.shared.Registration;
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.Message;
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.model.MessageType;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
@@ -33,13 +41,17 @@ import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
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)
|
||||
@PageTitle("Nachrichtenverlauf")
|
||||
@@ -63,8 +75,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
private Div scrollAnchor; // Anchor element for scrolling to bottom
|
||||
private List<Message> currentMessages; // Current messages being displayed for redrawing
|
||||
private Registration broadcasterRegistration; // Track listener registration
|
||||
private TextArea messageInput;
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
||||
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,
|
||||
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
|
||||
*/
|
||||
@@ -252,15 +401,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
.set("white-space", "pre-wrap")
|
||||
.set("text-align", alignment);
|
||||
|
||||
// Message content
|
||||
Div contentDiv = new Div();
|
||||
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);
|
||||
// Message content component (text or media)
|
||||
Component contentComponent = createContentComponent(message, alignment);
|
||||
|
||||
// Timestamp
|
||||
Span timeSpan = new Span(timestamp.format(TIME_FORMATTER));
|
||||
@@ -270,12 +412,147 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
.set("display", "block")
|
||||
.set("text-align", alignment);
|
||||
|
||||
bubble.add(contentDiv, timeSpan);
|
||||
bubble.add(contentComponent, timeSpan);
|
||||
messageWrapper.add(bubble);
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -326,12 +603,20 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
|
||||
|
||||
private HorizontalLayout createMessageInputArea() {
|
||||
TextArea messageInput = new TextArea();
|
||||
messageInput = new TextArea();
|
||||
messageInput.setPlaceholder("Nachricht eingeben...");
|
||||
messageInput.setWidthFull();
|
||||
messageInput.getStyle().set("min-height", "60px");
|
||||
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());
|
||||
sendButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY);
|
||||
sendButton.getStyle().set("height", "60px");
|
||||
@@ -339,30 +624,43 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
sendButton.addClickListener(e -> {
|
||||
String message = messageInput.getValue();
|
||||
if (message != null && !message.trim().isEmpty()) {
|
||||
sendMessageToParticipant(message.trim());
|
||||
sendMessageToParticipant(message, MessageContentType.TEXT);
|
||||
messageInput.clear();
|
||||
}
|
||||
});
|
||||
|
||||
HorizontalLayout layout = new HorizontalLayout(messageInput, sendButton);
|
||||
HorizontalLayout layout = new HorizontalLayout(attachButton, messageInput, sendButton);
|
||||
layout.setWidthFull();
|
||||
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.END);
|
||||
layout.setAlignItems(FlexComponent.Alignment.END);
|
||||
layout.setSpacing(true);
|
||||
layout.expand(messageInput);
|
||||
|
||||
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())
|
||||
.orElse("System");
|
||||
|
||||
try {
|
||||
Message saved;
|
||||
if (jobConversation) {
|
||||
saved = messageService.sendJobMessageToClient(content, sender, participantKey, jobIdContext,
|
||||
jobNumberContext);
|
||||
saved = messageService.sendJobMessageToClient(payload, sender, participantKey,
|
||||
contentType, jobIdContext, jobNumberContext);
|
||||
} else {
|
||||
saved = messageService.sendGeneralMessageToClient(content, sender, participantKey);
|
||||
saved = messageService.sendGeneralMessageToClient(payload, sender, participantKey,
|
||||
contentType);
|
||||
}
|
||||
|
||||
Notification.show("Nachricht gesendet", 2000, Notification.Position.BOTTOM_END)
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.vaadin.flow.router.Route;
|
||||
import de.assecutor.votianlt.dto.ClientMessageSummary;
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.Message;
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
import de.assecutor.votianlt.service.MessageService;
|
||||
@@ -207,7 +208,7 @@ public class MessagesView extends Main {
|
||||
.orElse(conversation.get(0));
|
||||
|
||||
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 unreadCount = (int) conversation.stream()
|
||||
.filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead())
|
||||
@@ -239,6 +240,20 @@ public class MessagesView extends Main {
|
||||
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) {
|
||||
if (message == null) {
|
||||
return null;
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.vaadin.flow.router.PageTitle;
|
||||
import com.vaadin.flow.router.Route;
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.Message;
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.model.MessageType;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
@@ -134,7 +135,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
.count();
|
||||
int messageCount = sortedMessages.size();
|
||||
LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null;
|
||||
String preview = latest != null ? latest.getContent() : null;
|
||||
String preview = resolvePreview(latest);
|
||||
|
||||
section.add(createMessageCard(
|
||||
"Allgemeine Unterhaltung",
|
||||
@@ -178,7 +179,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
String conversationTitle = "Auftrag " + jobKey;
|
||||
section.add(createMessageCard(
|
||||
conversationTitle,
|
||||
Optional.ofNullable(latest.getContent()).orElse(""),
|
||||
resolvePreview(latest),
|
||||
latest.getCreatedAt(),
|
||||
messages.size(),
|
||||
unreadCount,
|
||||
@@ -189,6 +190,20 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
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,
|
||||
LocalDateTime lastMessageTime, int messageCount,
|
||||
int unreadCount, String conversationId) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import de.assecutor.votianlt.model.Message;
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.model.MessageType;
|
||||
import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
|
||||
@@ -44,7 +45,12 @@ public class MessageService {
|
||||
* Send a general message to a client via MQTT
|
||||
*/
|
||||
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);
|
||||
publishMessageToMqtt(message, receiver);
|
||||
return message;
|
||||
@@ -55,7 +61,12 @@ public class MessageService {
|
||||
*/
|
||||
public Message sendJobMessageToClient(String content, String sender, String receiver,
|
||||
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);
|
||||
publishMessageToMqtt(message, receiver);
|
||||
return message;
|
||||
@@ -66,12 +77,13 @@ public class MessageService {
|
||||
*/
|
||||
public Message receiveMessageFromClient(ChatMessageInboundPayload payload) {
|
||||
Message message;
|
||||
MessageContentType contentType = payload.contentType();
|
||||
if (payload.hasJobContext()) {
|
||||
message = new Message(payload.content(), payload.sender(), payload.receiver(),
|
||||
MessageOrigin.CLIENT, payload.jobId(), payload.jobNumber());
|
||||
MessageOrigin.CLIENT, contentType, payload.jobId(), payload.jobNumber());
|
||||
} else {
|
||||
message = new Message(payload.content(), payload.sender(), payload.receiver(),
|
||||
MessageOrigin.CLIENT);
|
||||
MessageOrigin.CLIENT, contentType);
|
||||
}
|
||||
message = saveMessage(message);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user