Erweiterungen

This commit is contained in:
2025-10-09 13:47:26 +02:00
parent 9fa0534a5a
commit 304a9a0d0b
5 changed files with 331 additions and 32 deletions

View File

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

View File

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

View File

@@ -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) {
@@ -303,6 +319,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);
Notification.show("Nachricht konnte nicht gesendet werden: " + ex.getMessage(), 4000,
@@ -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);
}
});
}
}

View File

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

View File

@@ -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;
}
/**