From b7e19e7f929c68ccc42728719a2c08fb3b1fb81f Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Thu, 9 Oct 2025 14:20:21 +0200 Subject: [PATCH] Erweiterungen --- .../votianlt/model/MessageOrigin.java | 7 +- .../pages/view/MessageDetailsView.java | 429 +++++++----------- .../votianlt/pages/view/MessagesView.java | 4 +- .../votianlt/pages/view/UserMessagesView.java | 5 +- .../votianlt/service/MessageService.java | 4 +- 5 files changed, 169 insertions(+), 280 deletions(-) diff --git a/src/main/java/de/assecutor/votianlt/model/MessageOrigin.java b/src/main/java/de/assecutor/votianlt/model/MessageOrigin.java index 81e1105..039a325 100644 --- a/src/main/java/de/assecutor/votianlt/model/MessageOrigin.java +++ b/src/main/java/de/assecutor/votianlt/model/MessageOrigin.java @@ -7,12 +7,7 @@ public enum MessageOrigin { /** * Message received from a client (app user) */ - INCOMING, - - /** - * Message sent to a client (app user) - */ - OUTGOING, + CLIENT, /** * Message sent from the server 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 92e08ec..0b835c8 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java @@ -58,9 +58,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { private ObjectId jobIdContext; private String jobNumberContext; private final VerticalLayout contentLayout; - private VerticalLayout messagesContainer; + private VerticalLayout messagesContainer; // Container for message components private Scroller messagesScroller; // Reference to the scroller component - private Div scrollAnchor; // Marker element at the end of messages for scrolling + 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 static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy"); @@ -131,25 +132,23 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { HorizontalLayout headerLayout = createHeaderLayout(clientName, conversationTitle); contentLayout.add(headerLayout); - // Reset scrollAnchor when creating new messagesContainer to ensure it's properly re-added + // Store current messages for rendering and future updates + this.currentMessages = filteredMessages; + + // Reset scroll anchor for new container scrollAnchor = null; + // Create messages container messagesContainer = new VerticalLayout(); - messagesContainer.setPadding(true); - messagesContainer.setSpacing(true); + messagesContainer.setPadding(false); + messagesContainer.setSpacing(false); messagesContainer.setWidthFull(); - messagesContainer.getStyle().set("background-color", "#f0f0f0"); - messagesContainer.getStyle().set("border-radius", "8px"); - messagesContainer.getStyle().set("padding", "15px"); - messagesContainer.getStyle().set("display", "flex"); - messagesContainer.getStyle().set("flex-direction", "column"); - - if (!filteredMessages.isEmpty()) { - renderMessages(filteredMessages); - } else { - ensureScrollAnchor(); - } + messagesContainer.getStyle() + .set("background-color", "#f0f0f0") + .set("border-radius", "8px") + .set("padding", "15px"); + // Wrap messages container in scroller for vertical scrolling messagesScroller = new Scroller(messagesContainer); messagesScroller.setWidthFull(); messagesScroller.setHeightFull(); @@ -162,10 +161,142 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { HorizontalLayout inputLayout = createMessageInputArea(); contentLayout.add(inputLayout); - // Scroll to bottom to show the latest messages on initial page load - // This must be called AFTER messagesScroller is created and added to the layout - // Force scroll to ensure user sees the latest messages - scrollToBottom(true); + // Render messages using Vaadin components + renderMessages(); + + // Ensure scroll anchor exists and scroll to bottom + ensureScrollAnchor(); + scrollToBottom(); + } + + /** + * Render all messages using Vaadin components + */ + private void renderMessages() { + if (currentMessages == null || messagesContainer == null) { + return; + } + + messagesContainer.removeAll(); + + LocalDate currentDate = null; + + for (Message message : currentMessages) { + LocalDateTime timestamp = resolveTimestamp(message); + LocalDate messageDate = timestamp.toLocalDate(); + + // Add date separator if date changed + if (currentDate == null || !currentDate.equals(messageDate)) { + messagesContainer.add(createDateSeparator(messageDate)); + currentDate = messageDate; + } + + // Add message bubble + messagesContainer.add(createMessageBubble(message, timestamp)); + } + } + + /** + * Create a date separator component + */ + private Div createDateSeparator(LocalDate date) { + Div separator = new Div(); + separator.getStyle() + .set("display", "flex") + .set("justify-content", "center") + .set("text-align", "center") + .set("margin", "20px 0"); + separator.setWidthFull(); + + Span dateLabel = new Span(date.format(DATE_FORMATTER)); + dateLabel.getStyle() + .set("background-color", "#d0d0d0") + .set("padding", "4px 10px") + .set("border-radius", "12px") + .set("font-size", "12px") + .set("font-weight", "500") + .set("color", "#333333") + .set("display", "inline-block"); + + separator.add(dateLabel); + return separator; + } + + /** + * Create a message bubble component + */ + private Div createMessageBubble(Message message, LocalDateTime timestamp) { + // Determine alignment based on message origin + // CLIENT origin = client messages (left), SERVER origin = server messages (right) + boolean isServerMessage = message.getOrigin() == MessageOrigin.SERVER; + + // Container for the message (aligns left or right) + Div messageWrapper = new Div(); + String alignment = isServerMessage ? "right" : "left"; + + messageWrapper.getStyle() + .set("display", "flex") + .set("justify-content", isServerMessage ? "flex-end" : "flex-start") + .set("margin", "5px 0") + .set("width", "100%"); + + // Message bubble + Div bubble = new Div(); + bubble.getStyle() + .set("background-color", isServerMessage ? "#dcf8c6" : "#ffffff") + .set("padding", "10px 15px") + .set("border-radius", "18px") + .set("max-width", "70%") + .set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)") + .set("word-wrap", "break-word") + .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); + + // Timestamp + Span timeSpan = new Span(timestamp.format(TIME_FORMATTER)); + timeSpan.getStyle() + .set("font-size", "11px") + .set("color", isServerMessage ? "#666666" : "#999999") + .set("display", "block") + .set("text-align", alignment); + + bubble.add(contentDiv, timeSpan); + messageWrapper.add(bubble); + + return messageWrapper; + } + + /** + * Ensure scroll anchor exists at the bottom of messages container + */ + private void ensureScrollAnchor() { + if (scrollAnchor == null && messagesContainer != null) { + scrollAnchor = new Div(); + scrollAnchor.setId("scroll-anchor"); + scrollAnchor.getStyle() + .set("height", "1px") + .set("width", "100%"); + messagesContainer.add(scrollAnchor); + } + } + + /** + * Scroll to the bottom of the messages container + */ + private void scrollToBottom() { + if (scrollAnchor != null) { + scrollAnchor.scrollIntoView(); + } } private HorizontalLayout createHeaderLayout(String clientName, String conversationTitle) { @@ -193,85 +324,6 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { return layout; } - private Div createDateSeparator(LocalDateTime date) { - Div separator = new Div(); - separator.setWidthFull(); - separator.getStyle().set("text-align", "center"); - - Span dateSpan = new Span(date.format(DATE_FORMATTER)); - dateSpan.getStyle().set("background-color", "#d0d0d0"); - dateSpan.getStyle().set("padding", "8px 20px"); - dateSpan.getStyle().set("border-radius", "20px"); - dateSpan.getStyle().set("font-size", "12px"); - dateSpan.getStyle().set("color", "#333333"); - dateSpan.getStyle().set("font-weight", "500"); - dateSpan.getStyle().set("box-shadow", "0 1px 3px rgba(0,0,0,0.15)"); - - separator.add(dateSpan); - return separator; - } - - private Div createIncomingMessage(String content, LocalDateTime timestamp) { - Div messageWrapper = new Div(); - messageWrapper.setWidthFull(); - messageWrapper.getStyle().set("display", "flex"); - messageWrapper.getStyle().set("justify-content", "flex-start"); - - Div messageBubble = new Div(); - messageBubble.getStyle().set("max-width", "60%"); - messageBubble.getStyle().set("background-color", "#ffffff"); - messageBubble.getStyle().set("padding", "10px 15px"); - messageBubble.getStyle().set("border-radius", "18px"); - messageBubble.getStyle().set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)"); - - Span contentSpan = new Span(content); - contentSpan.getStyle().set("display", "block"); - contentSpan.getStyle().set("word-wrap", "break-word"); - - Span timeSpan = new Span(timestamp.format(TIME_FORMATTER)); - timeSpan.getStyle().set("font-size", "11px"); - timeSpan.getStyle().set("color", "#999999"); - timeSpan.getStyle().set("display", "block"); - timeSpan.getStyle().set("text-align", "right"); - timeSpan.getStyle().set("margin-top", "5px"); - - messageBubble.add(contentSpan, timeSpan); - messageWrapper.add(messageBubble); - - return messageWrapper; - } - - private Div createOutgoingMessage(String content, LocalDateTime timestamp) { - Div messageWrapper = new Div(); - messageWrapper.setWidthFull(); - messageWrapper.getStyle().set("display", "flex"); - messageWrapper.getStyle().set("justify-content", "flex-end"); - messageWrapper.getStyle().set("margin-bottom", "10px"); - - Div messageBubble = new Div(); - messageBubble.getStyle().set("max-width", "60%"); - messageBubble.getStyle().set("background-color", "#dcf8c6"); - messageBubble.getStyle().set("padding", "10px 15px"); - messageBubble.getStyle().set("padding-bottom", "5px"); - messageBubble.getStyle().set("border-radius", "18px"); - messageBubble.getStyle().set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)"); - - Span contentSpan = new Span(content); - contentSpan.getStyle().set("display", "block"); - contentSpan.getStyle().set("word-wrap", "break-word"); - - Span timeSpan = new Span(timestamp.format(TIME_FORMATTER)); - timeSpan.getStyle().set("font-size", "11px"); - timeSpan.getStyle().set("color", "#666666"); - timeSpan.getStyle().set("display", "block"); - timeSpan.getStyle().set("text-align", "right"); - timeSpan.getStyle().set("margin-top", "5px"); - - messageBubble.add(contentSpan, timeSpan); - messageWrapper.add(messageBubble); - - return messageWrapper; - } private HorizontalLayout createMessageInputArea() { TextArea messageInput = new TextArea(); @@ -316,13 +368,9 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { Notification.show("Nachricht gesendet", 2000, Notification.Position.BOTTOM_END) .addThemeVariants(NotificationVariant.LUMO_SUCCESS); - // Refresh conversation to include the new message and update counters + // Refresh conversation to include the new message and redraw canvas + // loadMessageDetails already scrolls to bottom after rendering loadMessageDetails(); - - // Ensure we scroll to the bottom to show the newly sent message - // This is called after loadMessageDetails to ensure DOM is fully updated - // Force scroll so user always sees their sent message - scrollToBottom(true); } catch (Exception ex) { log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex); @@ -412,147 +460,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { return "Konversation"; } - private void renderMessages(List messages) { - LocalDate currentDate = null; - Div lastMessageWrapper = null; - for (Message message : messages) { - LocalDateTime timestamp = resolveTimestamp(message); - LocalDate messageDate = timestamp.toLocalDate(); - if (!messageDate.equals(currentDate)) { - messagesContainer.add(createDateSeparator(timestamp)); - currentDate = messageDate; - } - - String content = Optional.ofNullable(message.getContent()).orElse("(kein Inhalt)"); - if (message.getOrigin() == MessageOrigin.INCOMING) { - lastMessageWrapper = createIncomingMessage(content, timestamp); - messagesContainer.add(lastMessageWrapper); - } else { - lastMessageWrapper = createOutgoingMessage(content, timestamp); - messagesContainer.add(lastMessageWrapper); - } - } - - ensureScrollAnchor(); - } - private LocalDateTime resolveTimestamp(Message message) { return Optional.ofNullable(message.getCreatedAt()).orElse(LocalDateTime.now()); } - private void ensureScrollAnchor() { - if (scrollAnchor == null) { - scrollAnchor = new Div(); - scrollAnchor.setId("scroll-anchor"); - scrollAnchor.getStyle().set("height", "5px"); - scrollAnchor.getStyle().set("width", "5px"); - scrollAnchor.getStyle().set("background-color", "red"); - } - if (scrollAnchor.getParent().isEmpty()) { - messagesContainer.add(scrollAnchor); - } - } - - /** - * Scroll the messages scroller to the bottom to show the scrollAnchor element - * Waits until all DOM elements are fully rendered before scrolling - * - * @param forceScroll If true, always scroll to bottom. If false, only scroll if user is near bottom (within 100px) - */ - private void scrollToBottom(boolean forceScroll) { - if (messagesScroller != null && scrollAnchor != null) { - // Use beforeClientResponse to ensure all components are rendered and DOM is ready - UI.getCurrent().beforeClientResponse(messagesScroller, context -> { - // Execute JS on the scroller element for more reliable DOM access - messagesScroller.getElement().executeJs( - "const scroller = this;" + - "const forceScroll = " + forceScroll + ";" + - "console.log('[ScrollToBottom] Starting scroll, forceScroll:', forceScroll);" + - - // Function to perform the actual scroll - "const performScroll = (scrollContainer, anchor) => {" + - " if (forceScroll) {" + - " anchor.scrollIntoView({ behavior: 'auto', block: 'end', inline: 'nearest' });" + - " console.log('[ScrollToBottom] Force scrolled to anchor');" + - " return;" + - " }" + - - " const scrollTop = scrollContainer.scrollTop;" + - " const scrollHeight = scrollContainer.scrollHeight;" + - " const clientHeight = scrollContainer.clientHeight;" + - " const distanceFromBottom = scrollHeight - scrollTop - clientHeight;" + - - " if (distanceFromBottom <= 100) {" + - " anchor.scrollIntoView({ behavior: 'auto', block: 'end', inline: 'nearest' });" + - " console.log('[ScrollToBottom] Scrolled to anchor (was near bottom)');" + - " } else {" + - " console.log('[ScrollToBottom] User scrolled up, not auto-scrolling');" + - " }" + - "};" + - - // Function to wait for DOM to be fully rendered by checking scrollHeight stability - "const waitForDOMReady = () => {" + - " if (!scroller || !scroller.shadowRoot) {" + - " console.log('[ScrollToBottom] No scroller or shadowRoot found, retrying...');" + - " setTimeout(waitForDOMReady, 50);" + - " return;" + - " }" + - - " const scrollContainer = scroller.shadowRoot.querySelector('[part=\"content\"]');" + - " if (!scrollContainer) {" + - " console.log('[ScrollToBottom] No scroll container found, retrying...');" + - " setTimeout(waitForDOMReady, 50);" + - " return;" + - " }" + - - " const anchor = scrollContainer.querySelector('#scroll-anchor');" + - " if (!anchor) {" + - " console.log('[ScrollToBottom] No anchor found, scrolling container to bottom');" + - " scrollContainer.scrollTop = scrollContainer.scrollHeight;" + - " return;" + - " }" + - - " let lastScrollHeight = 0;" + - " let stableCount = 0;" + - " const requiredStableChecks = 3;" + - " const maxAttempts = 50;" + - " let attempts = 0;" + - - " const checkStability = () => {" + - " attempts++;" + - " const currentScrollHeight = scrollContainer.scrollHeight;" + - - " if (currentScrollHeight === lastScrollHeight) {" + - " stableCount++;" + - " console.log('[ScrollToBottom] ScrollHeight stable (', stableCount, '/', requiredStableChecks, '), height:', currentScrollHeight);" + - " } else {" + - " stableCount = 0;" + - " console.log('[ScrollToBottom] ScrollHeight changed from', lastScrollHeight, 'to', currentScrollHeight);" + - " }" + - - " lastScrollHeight = currentScrollHeight;" + - - " if (stableCount >= requiredStableChecks) {" + - " console.log('[ScrollToBottom] DOM is stable, performing scroll');" + - " performScroll(scrollContainer, anchor);" + - " } else if (attempts >= maxAttempts) {" + - " console.log('[ScrollToBottom] Max attempts reached, performing scroll anyway');" + - " performScroll(scrollContainer, anchor);" + - " } else {" + - " requestAnimationFrame(checkStability);" + - " }" + - " };" + - - " requestAnimationFrame(checkStability);" + - "};" + - - // Add initial delay to allow Vaadin component to fully initialize - "setTimeout(waitForDOMReady, 100);" - ); - }); - } - } - /** * Called when the view is attached to the UI * Registers listener for incoming messages @@ -624,37 +535,21 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { // Update UI in a thread-safe manner using UI.access() ui.access(() -> { try { - if (messagesContainer != null) { - LocalDateTime timestamp = resolveTimestamp(message); - String content = Optional.ofNullable(message.getContent()).orElse("(kein Inhalt)"); + if (currentMessages != null) { + // Add new message to the list + currentMessages.add(message); - // Create and add the new message component - Div messageComponent; - if (message.getOrigin() == MessageOrigin.INCOMING) { - messageComponent = createIncomingMessage(content, timestamp); - } else { - messageComponent = createOutgoingMessage(content, timestamp); - } + // Re-render all messages with the new message included + renderMessages(); - // Remove scroll anchor temporarily - if (scrollAnchor != null && scrollAnchor.getParent().isPresent()) { - messagesContainer.remove(scrollAnchor); - } - - // Add new message - messagesContainer.add(messageComponent); - - // Re-add scroll anchor + // Ensure scroll anchor exists and scroll to show new message ensureScrollAnchor(); + scrollToBottom(); - // Scroll to show the new message (conditional - only if user is near bottom) - // This prevents interrupting users who are reading older messages - scrollToBottom(false); - - log.info("UI updated with new message"); + log.info("Messages re-rendered with new message"); } } catch (Exception e) { - log.error("Error updating UI with new message", e); + log.error("Error updating messages with new message", e); } }); } 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 7675c64..e2f0758 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java @@ -210,7 +210,7 @@ public class MessagesView extends Main { String preview = Optional.ofNullable(latest.getContent()).filter(s -> !s.isBlank()).orElse("(kein Inhalt)"); int totalMessages = conversation.size(); int unreadCount = (int) conversation.stream() - .filter(msg -> msg.getOrigin() == MessageOrigin.INCOMING && !msg.isRead()) + .filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead()) .count(); summary.setTotalMessages(summary.getTotalMessages() + totalMessages); @@ -243,7 +243,7 @@ public class MessagesView extends Main { if (message == null) { return null; } - if (message.getOrigin() == MessageOrigin.INCOMING) { + if (message.getOrigin() == MessageOrigin.CLIENT) { return message.getSender(); } return message.getReceiver(); 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 079757a..975c257 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java @@ -2,7 +2,6 @@ package de.assecutor.votianlt.pages.view; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.H2; import com.vaadin.flow.component.html.H3; @@ -131,7 +130,7 @@ public class UserMessagesView extends Main implements HasUrlParameter { Message latest = sortedMessages.isEmpty() ? null : sortedMessages.get(sortedMessages.size() - 1); int unreadCount = (int) sortedMessages.stream() - .filter(message -> message.getOrigin() == MessageOrigin.INCOMING && !message.isRead()) + .filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()) .count(); int messageCount = sortedMessages.size(); LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null; @@ -173,7 +172,7 @@ public class UserMessagesView extends Main implements HasUrlParameter { messages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo))); Message latest = messages.get(messages.size() - 1); int unreadCount = (int) messages.stream() - .filter(message -> message.getOrigin() == MessageOrigin.INCOMING && !message.isRead()) + .filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()) .count(); String conversationTitle = "Auftrag " + jobKey; diff --git a/src/main/java/de/assecutor/votianlt/service/MessageService.java b/src/main/java/de/assecutor/votianlt/service/MessageService.java index 87dc88c..50a89b6 100644 --- a/src/main/java/de/assecutor/votianlt/service/MessageService.java +++ b/src/main/java/de/assecutor/votianlt/service/MessageService.java @@ -68,10 +68,10 @@ public class MessageService { Message message; if (payload.hasJobContext()) { message = new Message(payload.content(), payload.sender(), payload.receiver(), - MessageOrigin.INCOMING, payload.jobId(), payload.jobNumber()); + MessageOrigin.CLIENT, payload.jobId(), payload.jobNumber()); } else { message = new Message(payload.content(), payload.sender(), payload.receiver(), - MessageOrigin.INCOMING); + MessageOrigin.CLIENT); } message = saveMessage(message);