Erweiterungen

This commit is contained in:
2025-10-09 14:20:21 +02:00
parent 304a9a0d0b
commit b7e19e7f92
5 changed files with 169 additions and 280 deletions

View File

@@ -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

View File

@@ -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<Message> 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<Message> 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);
}
});
}

View File

@@ -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();

View File

@@ -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<String> {
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<String> {
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;

View File

@@ -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);