From 304a9a0d0b3d67b6f9cc99275ab2ed04a226efd0 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Thu, 9 Oct 2025 13:47:26 +0200 Subject: [PATCH] Erweiterungen --- .../de/assecutor/votianlt/Application.java | 2 + .../votianlt/event/MessageReceivedEvent.java | 21 ++ .../pages/view/MessageDetailsView.java | 254 +++++++++++++++--- .../votianlt/service/MessageBroadcaster.java | 70 +++++ .../votianlt/service/MessageService.java | 16 +- 5 files changed, 331 insertions(+), 32 deletions(-) create mode 100644 src/main/java/de/assecutor/votianlt/event/MessageReceivedEvent.java create mode 100644 src/main/java/de/assecutor/votianlt/service/MessageBroadcaster.java diff --git a/src/main/java/de/assecutor/votianlt/Application.java b/src/main/java/de/assecutor/votianlt/Application.java index e9e9211..aabc2a2 100644 --- a/src/main/java/de/assecutor/votianlt/Application.java +++ b/src/main/java/de/assecutor/votianlt/Application.java @@ -1,6 +1,7 @@ package de.assecutor.votianlt; import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.component.page.Push; import com.vaadin.flow.theme.Theme; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -12,6 +13,7 @@ import java.time.Clock; @SpringBootApplication @EnableScheduling @Theme("default") +@Push public class Application implements AppShellConfigurator { @Bean diff --git a/src/main/java/de/assecutor/votianlt/event/MessageReceivedEvent.java b/src/main/java/de/assecutor/votianlt/event/MessageReceivedEvent.java new file mode 100644 index 0000000..939e8ce --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/event/MessageReceivedEvent.java @@ -0,0 +1,21 @@ +package de.assecutor.votianlt.event; + +import de.assecutor.votianlt.model.Message; +import org.springframework.context.ApplicationEvent; + +/** + * Event published when a new message is received from a client + */ +public class MessageReceivedEvent extends ApplicationEvent { + + private final Message message; + + public MessageReceivedEvent(Object source, Message message) { + super(source); + this.message = message; + } + + public Message getMessage() { + return message; + } +} 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 999046b..92e08ec 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java @@ -1,5 +1,7 @@ package de.assecutor.votianlt.pages.view; +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.html.Div; @@ -18,11 +20,13 @@ import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; 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.MessageOrigin; import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.pages.service.AppUserService; +import de.assecutor.votianlt.service.MessageBroadcaster; import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.security.SecurityService; import jakarta.annotation.security.RolesAllowed; @@ -46,6 +50,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { private final AppUserService appUserService; private final MessageService messageService; private final SecurityService securityService; + private final MessageBroadcaster messageBroadcaster; private String participantKey; private String conversationId; @@ -54,15 +59,18 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { private String jobNumberContext; private final VerticalLayout contentLayout; private VerticalLayout messagesContainer; + private Scroller messagesScroller; // Reference to the scroller component private Div scrollAnchor; // Marker element at the end of messages for scrolling + 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"); public MessageDetailsView(AppUserService appUserService, MessageService messageService, - SecurityService securityService) { + SecurityService securityService, MessageBroadcaster messageBroadcaster) { this.appUserService = appUserService; this.messageService = messageService; this.securityService = securityService; + this.messageBroadcaster = messageBroadcaster; // Set height to 100% to prevent page from growing beyond viewport setHeightFull(); @@ -123,6 +131,9 @@ 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 + scrollAnchor = null; + messagesContainer = new VerticalLayout(); messagesContainer.setPadding(true); messagesContainer.setSpacing(true); @@ -139,7 +150,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { ensureScrollAnchor(); } - Scroller messagesScroller = new Scroller(messagesContainer); + messagesScroller = new Scroller(messagesContainer); messagesScroller.setWidthFull(); messagesScroller.setHeightFull(); messagesScroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL); @@ -150,6 +161,11 @@ 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); } private HorizontalLayout createHeaderLayout(String clientName, String conversationTitle) { @@ -302,6 +318,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { // Refresh conversation to include the new message and update counters 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); @@ -413,8 +434,6 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } ensureScrollAnchor(); - - scrollToBottom(); } private LocalDateTime resolveTimestamp(Message message) { @@ -425,6 +444,9 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { 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); @@ -432,36 +454,208 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } /** - * Scroll the messages scroller to the bottom to show the latest message - * Uses scrollIntoView on the anchor element at the end of messages + * 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() { - if (scrollAnchor != null) { + 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(scrollAnchor, context -> { - // Use scrollIntoView on the anchor element - this is more reliable than - // trying to manipulate scrollTop/scrollHeight - // Multiple delayed attempts ensure content is fully rendered and laid out - scrollAnchor.getElement().executeJs( - "const anchor = this;" + - "console.log('Scroll anchor found:', anchor);" + - // First attempt after 50ms - instant scroll - "setTimeout(() => {" + - " anchor.scrollIntoView({ behavior: 'instant', block: 'end' });" + - " console.log('Scroll attempt 1: scrollIntoView called (instant)');" + - "}, 50);" + - // Second attempt after 200ms - instant scroll - "setTimeout(() => {" + - " anchor.scrollIntoView({ behavior: 'instant', block: 'end' });" + - " console.log('Scroll attempt 2: scrollIntoView called (instant)');" + - "}, 200);" + - // Third attempt after 500ms - instant scroll - "setTimeout(() => {" + - " anchor.scrollIntoView({ behavior: 'instant', block: 'end' });" + - " console.log('Scroll attempt 3: scrollIntoView called (instant)');" + - "}, 500);" + 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 + */ + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + UI ui = attachEvent.getUI(); + + // Register listener for incoming messages + broadcasterRegistration = messageBroadcaster.register(message -> { + handleIncomingMessage(ui, message); + }); + + log.info("MessageDetailsView attached and listener registered for conversation: {}", conversationId); + } + + /** + * Called when the view is detached from the UI + * Unregisters listener to prevent memory leaks + */ + @Override + protected void onDetach(DetachEvent detachEvent) { + if (broadcasterRegistration != null) { + broadcasterRegistration.remove(); + broadcasterRegistration = null; + log.info("MessageDetailsView detached and listener unregistered for conversation: {}", conversationId); + } + super.onDetach(detachEvent); + } + + /** + * Handle incoming message broadcast + * Filters messages to only show those belonging to the current conversation + */ + private void handleIncomingMessage(UI ui, Message message) { + if (message == null || participantKey == null || conversationId == null) { + return; + } + + // Check if message involves the current participant + boolean involvesParticipant = participantKey.equals(message.getSender()) + || participantKey.equals(message.getReceiver()); + + if (!involvesParticipant) { + log.debug("Message does not involve current participant, ignoring"); + return; + } + + // Check if message belongs to the current conversation + boolean belongsToConversation = false; + + if ("general".equalsIgnoreCase(conversationId)) { + // General conversation: messages without job context + belongsToConversation = message.getJobId() == null && message.getJobNumber() == null; + } else if (conversationId != null && conversationId.startsWith("job-")) { + // Job conversation: check if message matches the job + String token = conversationId.substring(4); + belongsToConversation = matchesJobConversation(message, token); + } + + if (!belongsToConversation) { + log.debug("Message does not belong to current conversation {}, ignoring", conversationId); + return; + } + + log.info("New message belongs to current conversation {}, updating UI", conversationId); + + // 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)"); + + // Create and add the new message component + Div messageComponent; + if (message.getOrigin() == MessageOrigin.INCOMING) { + messageComponent = createIncomingMessage(content, timestamp); + } else { + messageComponent = createOutgoingMessage(content, timestamp); + } + + // Remove scroll anchor temporarily + if (scrollAnchor != null && scrollAnchor.getParent().isPresent()) { + messagesContainer.remove(scrollAnchor); + } + + // Add new message + messagesContainer.add(messageComponent); + + // Re-add scroll anchor + ensureScrollAnchor(); + + // 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"); + } + } catch (Exception e) { + log.error("Error updating UI with new message", e); + } + }); + } } diff --git a/src/main/java/de/assecutor/votianlt/service/MessageBroadcaster.java b/src/main/java/de/assecutor/votianlt/service/MessageBroadcaster.java new file mode 100644 index 0000000..c0641e2 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/service/MessageBroadcaster.java @@ -0,0 +1,70 @@ +package de.assecutor.votianlt.service; + +import com.vaadin.flow.shared.Registration; +import de.assecutor.votianlt.event.MessageReceivedEvent; +import de.assecutor.votianlt.model.Message; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +/** + * Broadcaster service that manages listeners for incoming messages + * and notifies UI components in a thread-safe manner + */ +@Service +@Slf4j +public class MessageBroadcaster { + + private final Executor executor = Executors.newSingleThreadExecutor(); + private final LinkedHashSet> listeners = new LinkedHashSet<>(); + + /** + * Register a listener for incoming messages + * + * @param listener Consumer that will be called when a new message arrives + * @return Registration object that can be used to unregister the listener + */ + public synchronized Registration register(Consumer listener) { + listeners.add(listener); + log.debug("Registered message listener. Total listeners: {}", listeners.size()); + + return () -> { + synchronized (MessageBroadcaster.this) { + listeners.remove(listener); + log.debug("Unregistered message listener. Total listeners: {}", listeners.size()); + } + }; + } + + /** + * Broadcast a message to all registered listeners + * This is called asynchronously to avoid blocking the message reception + */ + private synchronized void broadcast(Message message) { + log.debug("Broadcasting message to {} listeners", listeners.size()); + for (Consumer listener : listeners) { + executor.execute(() -> { + try { + listener.accept(message); + } catch (Exception e) { + log.error("Error broadcasting message to listener", e); + } + }); + } + } + + /** + * Spring event listener that gets called when a MessageReceivedEvent is published + */ + @EventListener + public void onMessageReceived(MessageReceivedEvent event) { + Message message = event.getMessage(); + log.info("MessageBroadcaster received event for message from: {}", message.getSender()); + broadcast(message); + } +} diff --git a/src/main/java/de/assecutor/votianlt/service/MessageService.java b/src/main/java/de/assecutor/votianlt/service/MessageService.java index 6c193cc..87dc88c 100644 --- a/src/main/java/de/assecutor/votianlt/service/MessageService.java +++ b/src/main/java/de/assecutor/votianlt/service/MessageService.java @@ -5,10 +5,12 @@ import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.dto.ChatMessageInboundPayload; import de.assecutor.votianlt.dto.ChatMessageOutboundPayload; +import de.assecutor.votianlt.event.MessageReceivedEvent; import de.assecutor.votianlt.mqtt.MqttPublisher; import de.assecutor.votianlt.repository.MessageRepository; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.util.Collections; @@ -21,9 +23,13 @@ public class MessageService { private final MessageRepository messageRepository; private final MqttPublisher mqttPublisher; - public MessageService(MessageRepository messageRepository, MqttPublisher mqttPublisher) { + private final ApplicationEventPublisher eventPublisher; + + public MessageService(MessageRepository messageRepository, MqttPublisher mqttPublisher, + ApplicationEventPublisher eventPublisher) { this.messageRepository = messageRepository; this.mqttPublisher = mqttPublisher; + this.eventPublisher = eventPublisher; } /** @@ -67,7 +73,13 @@ public class MessageService { message = new Message(payload.content(), payload.sender(), payload.receiver(), MessageOrigin.INCOMING); } - return saveMessage(message); + message = saveMessage(message); + + // Publish event to notify UI components about the new message + log.info("Publishing MessageReceivedEvent for message from {}", message.getSender()); + eventPublisher.publishEvent(new MessageReceivedEvent(this, message)); + + return message; } /**