Erweiterungen
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user