Erweiterungen
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package de.assecutor.votianlt;
|
package de.assecutor.votianlt;
|
||||||
|
|
||||||
import com.vaadin.flow.component.page.AppShellConfigurator;
|
import com.vaadin.flow.component.page.AppShellConfigurator;
|
||||||
|
import com.vaadin.flow.component.page.Push;
|
||||||
import com.vaadin.flow.theme.Theme;
|
import com.vaadin.flow.theme.Theme;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -12,6 +13,7 @@ import java.time.Clock;
|
|||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@Theme("default")
|
@Theme("default")
|
||||||
|
@Push
|
||||||
public class Application implements AppShellConfigurator {
|
public class Application implements AppShellConfigurator {
|
||||||
|
|
||||||
@Bean
|
@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;
|
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.UI;
|
||||||
import com.vaadin.flow.component.button.Button;
|
import com.vaadin.flow.component.button.Button;
|
||||||
import com.vaadin.flow.component.html.Div;
|
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.PageTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import com.vaadin.flow.router.RouteParameters;
|
import com.vaadin.flow.router.RouteParameters;
|
||||||
|
import com.vaadin.flow.shared.Registration;
|
||||||
import de.assecutor.votianlt.model.AppUser;
|
import de.assecutor.votianlt.model.AppUser;
|
||||||
import de.assecutor.votianlt.model.Message;
|
import de.assecutor.votianlt.model.Message;
|
||||||
import de.assecutor.votianlt.model.MessageOrigin;
|
import de.assecutor.votianlt.model.MessageOrigin;
|
||||||
import de.assecutor.votianlt.model.MessageType;
|
import de.assecutor.votianlt.model.MessageType;
|
||||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||||
|
import de.assecutor.votianlt.service.MessageBroadcaster;
|
||||||
import de.assecutor.votianlt.service.MessageService;
|
import de.assecutor.votianlt.service.MessageService;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
@@ -46,6 +50,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
private final AppUserService appUserService;
|
private final AppUserService appUserService;
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
|
private final MessageBroadcaster messageBroadcaster;
|
||||||
|
|
||||||
private String participantKey;
|
private String participantKey;
|
||||||
private String conversationId;
|
private String conversationId;
|
||||||
@@ -54,15 +59,18 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
private String jobNumberContext;
|
private String jobNumberContext;
|
||||||
private final VerticalLayout contentLayout;
|
private final VerticalLayout contentLayout;
|
||||||
private VerticalLayout messagesContainer;
|
private VerticalLayout messagesContainer;
|
||||||
|
private Scroller messagesScroller; // Reference to the scroller component
|
||||||
private Div scrollAnchor; // Marker element at the end of messages for scrolling
|
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 TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
||||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||||
|
|
||||||
public MessageDetailsView(AppUserService appUserService, MessageService messageService,
|
public MessageDetailsView(AppUserService appUserService, MessageService messageService,
|
||||||
SecurityService securityService) {
|
SecurityService securityService, MessageBroadcaster messageBroadcaster) {
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
|
this.messageBroadcaster = messageBroadcaster;
|
||||||
|
|
||||||
// Set height to 100% to prevent page from growing beyond viewport
|
// Set height to 100% to prevent page from growing beyond viewport
|
||||||
setHeightFull();
|
setHeightFull();
|
||||||
@@ -123,6 +131,9 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
HorizontalLayout headerLayout = createHeaderLayout(clientName, conversationTitle);
|
HorizontalLayout headerLayout = createHeaderLayout(clientName, conversationTitle);
|
||||||
contentLayout.add(headerLayout);
|
contentLayout.add(headerLayout);
|
||||||
|
|
||||||
|
// Reset scrollAnchor when creating new messagesContainer to ensure it's properly re-added
|
||||||
|
scrollAnchor = null;
|
||||||
|
|
||||||
messagesContainer = new VerticalLayout();
|
messagesContainer = new VerticalLayout();
|
||||||
messagesContainer.setPadding(true);
|
messagesContainer.setPadding(true);
|
||||||
messagesContainer.setSpacing(true);
|
messagesContainer.setSpacing(true);
|
||||||
@@ -139,7 +150,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
ensureScrollAnchor();
|
ensureScrollAnchor();
|
||||||
}
|
}
|
||||||
|
|
||||||
Scroller messagesScroller = new Scroller(messagesContainer);
|
messagesScroller = new Scroller(messagesContainer);
|
||||||
messagesScroller.setWidthFull();
|
messagesScroller.setWidthFull();
|
||||||
messagesScroller.setHeightFull();
|
messagesScroller.setHeightFull();
|
||||||
messagesScroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
|
messagesScroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
|
||||||
@@ -150,6 +161,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
|
|
||||||
HorizontalLayout inputLayout = createMessageInputArea();
|
HorizontalLayout inputLayout = createMessageInputArea();
|
||||||
contentLayout.add(inputLayout);
|
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) {
|
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
|
// Refresh conversation to include the new message and update counters
|
||||||
loadMessageDetails();
|
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) {
|
} catch (Exception ex) {
|
||||||
log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex);
|
log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex);
|
||||||
@@ -413,8 +434,6 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureScrollAnchor();
|
ensureScrollAnchor();
|
||||||
|
|
||||||
scrollToBottom();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private LocalDateTime resolveTimestamp(Message message) {
|
private LocalDateTime resolveTimestamp(Message message) {
|
||||||
@@ -425,6 +444,9 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
if (scrollAnchor == null) {
|
if (scrollAnchor == null) {
|
||||||
scrollAnchor = new Div();
|
scrollAnchor = new Div();
|
||||||
scrollAnchor.setId("scroll-anchor");
|
scrollAnchor.setId("scroll-anchor");
|
||||||
|
scrollAnchor.getStyle().set("height", "5px");
|
||||||
|
scrollAnchor.getStyle().set("width", "5px");
|
||||||
|
scrollAnchor.getStyle().set("background-color", "red");
|
||||||
}
|
}
|
||||||
if (scrollAnchor.getParent().isEmpty()) {
|
if (scrollAnchor.getParent().isEmpty()) {
|
||||||
messagesContainer.add(scrollAnchor);
|
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
|
* Scroll the messages scroller to the bottom to show the scrollAnchor element
|
||||||
* Uses scrollIntoView on the anchor element at the end of messages
|
* 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() {
|
private void scrollToBottom(boolean forceScroll) {
|
||||||
if (scrollAnchor != null) {
|
if (messagesScroller != null && scrollAnchor != null) {
|
||||||
// Use beforeClientResponse to ensure all components are rendered and DOM is ready
|
// Use beforeClientResponse to ensure all components are rendered and DOM is ready
|
||||||
UI.getCurrent().beforeClientResponse(scrollAnchor, context -> {
|
UI.getCurrent().beforeClientResponse(messagesScroller, context -> {
|
||||||
// Use scrollIntoView on the anchor element - this is more reliable than
|
// Execute JS on the scroller element for more reliable DOM access
|
||||||
// trying to manipulate scrollTop/scrollHeight
|
messagesScroller.getElement().executeJs(
|
||||||
// Multiple delayed attempts ensure content is fully rendered and laid out
|
"const scroller = this;" +
|
||||||
scrollAnchor.getElement().executeJs(
|
"const forceScroll = " + forceScroll + ";" +
|
||||||
"const anchor = this;" +
|
"console.log('[ScrollToBottom] Starting scroll, forceScroll:', forceScroll);" +
|
||||||
"console.log('Scroll anchor found:', anchor);" +
|
|
||||||
// First attempt after 50ms - instant scroll
|
// Function to perform the actual scroll
|
||||||
"setTimeout(() => {" +
|
"const performScroll = (scrollContainer, anchor) => {" +
|
||||||
" anchor.scrollIntoView({ behavior: 'instant', block: 'end' });" +
|
" if (forceScroll) {" +
|
||||||
" console.log('Scroll attempt 1: scrollIntoView called (instant)');" +
|
" anchor.scrollIntoView({ behavior: 'auto', block: 'end', inline: 'nearest' });" +
|
||||||
"}, 50);" +
|
" console.log('[ScrollToBottom] Force scrolled to anchor');" +
|
||||||
// Second attempt after 200ms - instant scroll
|
" return;" +
|
||||||
"setTimeout(() => {" +
|
" }" +
|
||||||
" anchor.scrollIntoView({ behavior: 'instant', block: 'end' });" +
|
|
||||||
" console.log('Scroll attempt 2: scrollIntoView called (instant)');" +
|
" const scrollTop = scrollContainer.scrollTop;" +
|
||||||
"}, 200);" +
|
" const scrollHeight = scrollContainer.scrollHeight;" +
|
||||||
// Third attempt after 500ms - instant scroll
|
" const clientHeight = scrollContainer.clientHeight;" +
|
||||||
"setTimeout(() => {" +
|
" const distanceFromBottom = scrollHeight - scrollTop - clientHeight;" +
|
||||||
" anchor.scrollIntoView({ behavior: 'instant', block: 'end' });" +
|
|
||||||
" console.log('Scroll attempt 3: scrollIntoView called (instant)');" +
|
" if (distanceFromBottom <= 100) {" +
|
||||||
"}, 500);"
|
" 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.model.MessageType;
|
||||||
import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
|
import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
|
||||||
import de.assecutor.votianlt.dto.ChatMessageOutboundPayload;
|
import de.assecutor.votianlt.dto.ChatMessageOutboundPayload;
|
||||||
|
import de.assecutor.votianlt.event.MessageReceivedEvent;
|
||||||
import de.assecutor.votianlt.mqtt.MqttPublisher;
|
import de.assecutor.votianlt.mqtt.MqttPublisher;
|
||||||
import de.assecutor.votianlt.repository.MessageRepository;
|
import de.assecutor.votianlt.repository.MessageRepository;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
@@ -21,9 +23,13 @@ public class MessageService {
|
|||||||
|
|
||||||
private final MessageRepository messageRepository;
|
private final MessageRepository messageRepository;
|
||||||
private final MqttPublisher mqttPublisher;
|
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.messageRepository = messageRepository;
|
||||||
this.mqttPublisher = mqttPublisher;
|
this.mqttPublisher = mqttPublisher;
|
||||||
|
this.eventPublisher = eventPublisher;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +73,13 @@ public class MessageService {
|
|||||||
message = new Message(payload.content(), payload.sender(), payload.receiver(),
|
message = new Message(payload.content(), payload.sender(), payload.receiver(),
|
||||||
MessageOrigin.INCOMING);
|
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