diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java b/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java index c43ee7c..260a651 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java @@ -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 sendGeneralMessage(@RequestBody Map 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 sendJobMessage(@RequestBody Map 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); + } + } } diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/src/main/java/de/assecutor/votianlt/controller/MessageController.java index ec0318f..02fca94 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -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" * } diff --git a/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java b/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java index 6a8e207..6225396 100644 --- a/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java +++ b/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java @@ -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 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); + } + } } diff --git a/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java b/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java index 3dc2cea..f8cf67e 100644 --- a/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java +++ b/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java @@ -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(), diff --git a/src/main/java/de/assecutor/votianlt/model/Message.java b/src/main/java/de/assecutor/votianlt/model/Message.java index 3440b5c..e4aaede 100644 --- a/src/main/java/de/assecutor/votianlt/model/Message.java +++ b/src/main/java/de/assecutor/votianlt/model/Message.java @@ -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,29 +94,35 @@ 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; } /** * Constructor for job-related messages */ - 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; + public Message(String content, String sender, String receiver, MessageOrigin origin, + ObjectId jobId, String jobNumber) { + 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; + } } diff --git a/src/main/java/de/assecutor/votianlt/model/MessageContentType.java b/src/main/java/de/assecutor/votianlt/model/MessageContentType.java new file mode 100644 index 0000000..faf756e --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/MessageContentType.java @@ -0,0 +1,9 @@ +package de.assecutor.votianlt.model; + +/** + * Supported content variants for chat messages. + */ +public enum MessageContentType { + TEXT, + IMAGE +} diff --git a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java index 0b835c8..ef672c7 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java @@ -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 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 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,43 +603,64 @@ 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"); - + 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) diff --git a/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java b/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java index e2f0758..0b94a1d 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java @@ -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; diff --git a/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java b/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java index 975c257..69b0dd9 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java @@ -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 { .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 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 { 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) { diff --git a/src/main/java/de/assecutor/votianlt/service/MessageService.java b/src/main/java/de/assecutor/votianlt/service/MessageService.java index 50a89b6..9706ba6 100644 --- a/src/main/java/de/assecutor/votianlt/service/MessageService.java +++ b/src/main/java/de/assecutor/votianlt/service/MessageService.java @@ -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; @@ -53,9 +59,14 @@ public class MessageService { /** * Send a job-related message to a client via MQTT */ - public Message sendJobMessageToClient(String content, String sender, String receiver, - ObjectId jobId, String jobNumber) { - Message message = new Message(content, sender, receiver, MessageOrigin.SERVER, jobId, jobNumber); + public Message sendJobMessageToClient(String content, String sender, String receiver, + ObjectId jobId, String 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);