Erweiterungen

This commit is contained in:
2025-10-14 19:28:20 +02:00
parent 4440825024
commit b391dbec8d
9 changed files with 354 additions and 62 deletions

View File

@@ -9,6 +9,7 @@ import de.assecutor.votianlt.model.CargoItem;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.pages.service.UserService;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.CargoItemRepository;
import de.assecutor.votianlt.repository.JobRepository;
@@ -49,6 +50,9 @@ public class MessageController {
// Map to store userId -> clientId mapping for active sessions
private final Map<String, String> userClientIdMapping = new ConcurrentHashMap<>();
// Map to store clientId -> userId mapping for active sessions (reverse lookup)
private final Map<String, String> clientIdUserMapping = new ConcurrentHashMap<>();
private final MqttPublisher mqttPublisher;
private final AppUserRepository appUserRepository;
@@ -67,12 +71,13 @@ public class MessageController {
private final JobHistoryService jobHistoryService;
private final EmailService emailService;
private final MessageService messageService;
private final UserService userService;
public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository,
AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService,
EmailService emailService, MessageService messageService) {
SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService,
EmailService emailService, MessageService messageService, UserService userService) {
this.mqttPublisher = mqttPublisher;
this.appUserRepository = appUserRepository;
this.appUserService = appUserService;
@@ -86,6 +91,7 @@ public class MessageController {
this.jobHistoryService = jobHistoryService;
this.emailService = emailService;
this.messageService = messageService;
this.userService = userService;
}
/**
@@ -585,7 +591,8 @@ public class MessageController {
*/
private void storeClientIdMapping(String userId, String clientId) {
userClientIdMapping.put(userId, clientId);
log.debug("Stored clientId mapping: userId={} -> clientId={}", userId, clientId);
clientIdUserMapping.put(clientId, userId);
log.debug("Stored clientId mapping: userId={} <-> clientId={}", userId, clientId);
}
/**
@@ -595,6 +602,13 @@ public class MessageController {
return userClientIdMapping.get(userId);
}
/**
* Get the userId (AppUser ID) for a given clientId
*/
private String getUserIdForClientId(String clientId) {
return clientIdUserMapping.get(clientId);
}
/**
* Handle incoming message from a client via MQTT.
* Client sends to /server/{clientId}/message with payload:
@@ -604,21 +618,83 @@ public class MessageController {
* "content": "message payload",
* "contentType": "TEXT|IMAGE",
* "jobId": "optional job id",
* "jobNumber": "optional job number"
* "jobNumber": "optional job number",
* "clientId": "extracted from topic"
* }
*
* Logic:
* 1. Extract clientId from topic (this is the AppUser ID)
* 2. Find AppUser by ID in database
* 3. Get owner (User) from AppUser.owner field
* 4. Set receiver = User ID, sender = AppUser ID
*/
public void handleIncomingMessage(Map<String, Object> payload) {
log.info("MQTT Endpoint '/server/{clientId}/message' called with data: {}", payload);
try {
ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload);
messageService.receiveMessageFromClient(inboundPayload);
log.info("Successfully saved incoming message from {} to {}", inboundPayload.sender(),
inboundPayload.receiver());
// Extract clientId from payload (added by MqttV5ClientManager from topic)
// The clientId IS the AppUser ID
String clientId = payload.get("clientId") != null ? payload.get("clientId").toString() : null;
if (clientId == null || clientId.isBlank()) {
log.warn("No clientId found in message payload, cannot process message");
return;
}
// Convert clientId (AppUser ID) to ObjectId
ObjectId appUserObjectId;
try {
appUserObjectId = new ObjectId(clientId);
} catch (IllegalArgumentException e) {
log.warn("Invalid clientId/AppUser ID '{}': {}", clientId, e.getMessage());
return;
}
// Find AppUser by ID
AppUser appUser = appUserService.findById(appUserObjectId);
if (appUser == null) {
log.warn("AppUser not found for clientId '{}'", clientId);
return;
}
// Get owner (User) of AppUser from the owner field
ObjectId ownerId = appUser.getOwner();
if (ownerId == null) {
log.warn("AppUser '{}' has no owner, cannot determine receiver", clientId);
return;
}
// Verify that owner exists
de.assecutor.votianlt.model.User owner = userService.findById(ownerId);
if (owner == null) {
log.warn("Owner User not found for AppUser '{}'", clientId);
return;
}
// Convert owner ID to string for receiver field
String ownerIdString = ownerId.toHexString();
// Create payload with:
// - sender = AppUser ID (clientId)
// - receiver = User ID (owner's ID as string)
ChatMessageInboundPayload resolvedPayload = new ChatMessageInboundPayload(
clientId, // sender = AppUser ID
ownerIdString, // receiver = User ID
inboundPayload.content(),
inboundPayload.contentType(),
inboundPayload.jobId(),
inboundPayload.jobNumber()
);
messageService.receiveMessageFromClient(resolvedPayload);
log.info("Successfully saved incoming message from AppUser '{}' to User '{}'", clientId, ownerIdString);
} catch (IllegalArgumentException validationError) {
log.warn("Incoming chat message rejected: {}", validationError.getMessage());
} catch (Exception e) {
log.error("Error handling incoming message: {}", e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,15 @@
package de.assecutor.votianlt.event;
import org.springframework.context.ApplicationEvent;
/**
* Event published when message read status changes (e.g., messages marked as read)
* This allows UI components like the sidebar badge to update accordingly
*/
public class MessageReadStatusChangedEvent extends ApplicationEvent {
public MessageReadStatusChangedEvent(Object source) {
super(source);
}
}

View File

@@ -165,7 +165,14 @@ public class MqttV5ClientManager implements SmartLifecycle {
messageController.handleAppLogin(req);
} else if (topic.matches("/server/.+/message")) {
try {
// Handle incoming message from client
// Extract clientId from topic: /server/{clientId}/message
String[] parts = topic.split("/");
String clientId = parts.length > 2 ? parts[2] : null;
if (clientId != null && !clientId.isBlank()) {
payload.put("clientId", clientId);
} else {
log.warn("Couldn't extract clientId from topic {} for message", topic);
}
messageController.handleIncomingMessage(payload);
} catch (Exception e) {
log.error("Error handling incoming message on {}: {}", topic, e.getMessage(), e);

View File

@@ -1,6 +1,8 @@
package de.assecutor.votianlt.pages.base.ui.view;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.avatar.Avatar;
@@ -20,12 +22,15 @@ import com.vaadin.flow.router.Layout;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.flow.server.menu.MenuConfiguration;
import com.vaadin.flow.server.menu.MenuEntry;
import com.vaadin.flow.shared.Registration;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.UserInvoiceData;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.pages.view.EditProfileView;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.MessageBadgeUpdateService;
import de.assecutor.votianlt.service.MessageService;
import lombok.extern.slf4j.Slf4j;
import static com.vaadin.flow.theme.lumo.LumoUtility.*;
@@ -34,21 +39,27 @@ import java.util.Optional;
import java.util.Set;
@AnonymousAllowed
@Slf4j
@Layout("main")
public final class MainLayout extends AppLayout {
private final SecurityService securityService;
private final UserInvoiceDataService userInvoiceDataService;
private final MessageService messageService;
private final MessageBadgeUpdateService messageBadgeUpdateService;
private Div headerRef;
private Scroller navRef;
private Component userMenuRef;
private Span messagesBadge; // Reference to the messages badge for dynamic updates
private SideNavItem messagesNavItem; // Reference to the messages nav item
private Registration badgeUpdateRegistration; // Track badge update listener registration
public MainLayout(SecurityService securityService, UserInvoiceDataService userInvoiceDataService, MessageService messageService) {
public MainLayout(SecurityService securityService, UserInvoiceDataService userInvoiceDataService,
MessageService messageService, MessageBadgeUpdateService messageBadgeUpdateService) {
this.securityService = securityService;
this.userInvoiceDataService = userInvoiceDataService;
this.messageService = messageService;
this.messageBadgeUpdateService = messageBadgeUpdateService;
setPrimarySection(Section.DRAWER);
// Always build the drawer; keep references and toggle visibility on attach and
@@ -162,24 +173,44 @@ public final class MainLayout extends AppLayout {
item = new SideNavItem(menuEntry.title(), menuEntry.path());
}
if ("Nachrichten".equals(menuEntry.title())) {
long unreadCount = resolveUnreadMessageCount();
if (unreadCount > 0) {
Span badge = new Span(String.valueOf(unreadCount));
badge.getElement().getThemeList().add("badge");
badge.getStyle().set("background-color", "var(--lumo-primary-color)");
badge.getStyle().set("color", "#ffffff");
badge.getStyle().set("border-radius", "12px");
badge.getStyle().set("padding", "2px 8px");
badge.getStyle().set("font-size", "12px");
badge.getStyle().set("font-weight", "bold");
badge.getStyle().set("min-width", "20px");
badge.getStyle().set("text-align", "center");
messagesNavItem = item;
}
return item;
}
item.setSuffixComponent(badge);
/**
* Updates the messages badge with the current unread count
*/
private void updateMessagesBadge() {
if (messagesNavItem == null) {
return;
}
long unreadCount = resolveUnreadMessageCount();
if (unreadCount > 0) {
if (messagesBadge == null) {
messagesBadge = new Span(String.valueOf(unreadCount));
messagesBadge.getElement().getThemeList().add("badge");
messagesBadge.getStyle().set("background-color", "var(--lumo-primary-color)");
messagesBadge.getStyle().set("color", "#ffffff");
messagesBadge.getStyle().set("border-radius", "12px");
messagesBadge.getStyle().set("padding", "2px 8px");
messagesBadge.getStyle().set("font-size", "12px");
messagesBadge.getStyle().set("font-weight", "bold");
messagesBadge.getStyle().set("min-width", "20px");
messagesBadge.getStyle().set("text-align", "center");
messagesNavItem.setSuffixComponent(messagesBadge);
} else {
messagesBadge.setText(String.valueOf(unreadCount));
messagesBadge.setVisible(true);
}
} else {
if (messagesBadge != null) {
messagesNavItem.setSuffixComponent(null);
messagesBadge = null;
}
}
return item;
}
private long resolveUnreadMessageCount() {
@@ -191,6 +222,7 @@ public final class MainLayout extends AppLayout {
try {
User currentUser = securityService.getCurrentDatabaseUser();
if (currentUser != null) {
String email = Optional.ofNullable(currentUser.getEmail()).map(String::trim).orElse("");
if (!email.isBlank()) {
@@ -217,6 +249,7 @@ public final class MainLayout extends AppLayout {
for (String receiver : candidateReceivers) {
unread += messageService.getUnreadMessageCount(receiver);
}
return unread;
}
@@ -269,4 +302,28 @@ public final class MainLayout extends AppLayout {
return false;
}
}
@Override
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);
UI ui = attachEvent.getUI();
// Update badge immediately when layout is attached
updateMessagesBadge();
// Register listener for badge updates
badgeUpdateRegistration = messageBadgeUpdateService.register(() -> {
ui.access(() -> {
updateMessagesBadge();
});
});
}
@Override
protected void onDetach(DetachEvent detachEvent) {
if (badgeUpdateRegistration != null) {
badgeUpdateRegistration.remove();
badgeUpdateRegistration = null;
}
super.onDetach(detachEvent);
}
}

View File

@@ -59,6 +59,14 @@ public class AppUserService {
return appUserRepository.findById(id).orElse(null);
}
public AppUser findByEmail(String email) {
return appUserRepository.findByEmail(email);
}
public AppUser findByBezeichnung(String bezeichnung) {
return appUserRepository.findByBezeichnung(bezeichnung);
}
public AppUser updateAppUser(AppUser appUser) {
// Hash the password if it's being updated and not empty
if (appUser.getPassword() != null && !appUser.getPassword().isEmpty()) {

View File

@@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.UserRepository;
import org.bson.types.ObjectId;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@@ -22,6 +23,10 @@ public class UserService {
return userRepository.findByEmail(email).orElse(null);
}
public User findById(ObjectId id) {
return userRepository.findById(id).orElse(null);
}
public User save(User user) {
if (user.getPassword() != null && !user.getPassword().startsWith("$2a$")) {
// Passwort verschlüsseln, falls noch nicht verschlüsselt

View File

@@ -38,6 +38,8 @@ 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 de.assecutor.votianlt.event.MessageReadStatusChangedEvent;
import org.springframework.context.ApplicationEventPublisher;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
@@ -75,6 +77,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private final MessageService messageService;
private final SecurityService securityService;
private final MessageBroadcaster messageBroadcaster;
private final ApplicationEventPublisher eventPublisher;
private String participantKey;
private String conversationId;
@@ -95,15 +98,17 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private static final float JPEG_COMPRESSION_QUALITY = 0.8f;
public MessageDetailsView(AppUserService appUserService, MessageService messageService,
SecurityService securityService, MessageBroadcaster messageBroadcaster) {
SecurityService securityService, MessageBroadcaster messageBroadcaster,
ApplicationEventPublisher eventPublisher) {
this.appUserService = appUserService;
this.messageService = messageService;
this.securityService = securityService;
this.messageBroadcaster = messageBroadcaster;
this.eventPublisher = eventPublisher;
// Set height to 100% to prevent page from growing beyond viewport
setHeightFull();
// Create main layout with fixed positioning
contentLayout = new VerticalLayout();
contentLayout.setPadding(true);
@@ -119,7 +124,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
public void beforeEnter(BeforeEnterEvent event) {
// Extract route parameters from URL
RouteParameters parameters = event.getRouteParameters();
this.participantKey = parameters.get("clientId").orElse(null);
this.conversationId = parameters.get("conversationId").orElse(null);
@@ -164,10 +169,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// 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(false);
@@ -190,10 +195,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
HorizontalLayout inputLayout = createMessageInputArea();
contentLayout.add(inputLayout);
// Render messages using Vaadin components
renderMessages();
// Ensure scroll anchor exists and scroll to bottom
ensureScrollAnchor();
scrollToBottom();
@@ -210,22 +215,54 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// Reset anchor so a fresh one can be attached after re-rendering
scrollAnchor = null;
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));
}
// After rendering, mark any unread messages directed to the current user as read
markVisibleMessagesAsRead();
}
/**
* Marks all currently visible messages that are addressed to the logged-in user as read.
* This is triggered after (re)rendering the conversation and will also update the in-memory
* message objects to keep UI state consistent.
*/
private void markVisibleMessagesAsRead() {
try {
if (currentMessages == null || currentMessages.isEmpty()) {
return;
}
boolean anyMarked = false;
for (Message msg : currentMessages) {
if (!msg.isRead() && msg.getId() != null) {
// Update persistence
messageService.markAsRead(msg.getId());
// Update in-memory object so UI reflects read state immediately
msg.markAsRead();
anyMarked = true;
}
}
// Publish event to update badge in sidebar
if (anyMarked) {
eventPublisher.publishEvent(new MessageReadStatusChangedEvent(this));
}
} catch (Exception e) {
log.warn("Failed to mark messages as read: {}", e.getMessage());
}
}
private void openImageUploadDialog() {
@@ -372,7 +409,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("text-align", "center")
.set("margin", "20px 0");
separator.setWidthFull();
Span dateLabel = new Span(date.format(DATE_FORMATTER));
dateLabel.getStyle()
.set("background-color", "#d0d0d0")
@@ -382,7 +419,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("font-weight", "500")
.set("color", "#333333")
.set("display", "inline-block");
separator.add(dateLabel);
return separator;
}
@@ -394,7 +431,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// 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";
@@ -404,7 +441,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("justify-content", isServerMessage ? "flex-end" : "flex-start")
.set("margin", "5px 0")
.set("width", "100%");
// Message bubble
Div bubble = new Div();
bubble.getStyle()
@@ -416,10 +453,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("word-wrap", "break-word")
.set("white-space", "pre-wrap")
.set("text-align", alignment);
// Message content component (text or media)
Component contentComponent = createContentComponent(message, alignment);
// Timestamp
Span timeSpan = new Span(timestamp.format(TIME_FORMATTER));
timeSpan.getStyle()
@@ -427,10 +464,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("color", isServerMessage ? "#666666" : "#999999")
.set("display", "block")
.set("text-align", alignment);
bubble.add(contentComponent, timeSpan);
messageWrapper.add(bubble);
return messageWrapper;
}
@@ -692,25 +729,25 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private HorizontalLayout createHeaderLayout(String clientName, String conversationTitle) {
Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create());
backButton.addClickListener(e -> UI.getCurrent().navigate("user-messages/" + participantKey));
VerticalLayout titleLayout = new VerticalLayout();
titleLayout.setPadding(false);
titleLayout.setSpacing(false);
H2 title = new H2(clientName);
title.getStyle().set("margin", "0");
Span subtitle = new Span(conversationTitle);
subtitle.getStyle().set("color", "#666666");
subtitle.getStyle().set("font-size", "14px");
titleLayout.add(title, subtitle);
HorizontalLayout layout = new HorizontalLayout(backButton, titleLayout);
layout.setWidthFull();
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
layout.setSpacing(true);
return layout;
}
@@ -778,6 +815,16 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
contentType);
}
// Mark own outgoing message as read immediately
if (saved != null && saved.getId() != null) {
try {
messageService.markAsRead(saved.getId());
saved.markAsRead(); // keep UI state consistent
} catch (Exception ignore) {
// non-fatal
}
}
Notification.show("Nachricht gesendet", 2000, Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
@@ -939,12 +986,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
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);
}
@@ -972,9 +1019,9 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
}
// Check if message involves the current participant
boolean involvesParticipant = participantKey.equals(message.getSender())
boolean involvesParticipant = participantKey.equals(message.getSender())
|| participantKey.equals(message.getReceiver());
if (!involvesParticipant) {
log.debug("Message does not involve current participant, ignoring");
return;
@@ -982,7 +1029,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// 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;
@@ -998,7 +1045,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
}
log.info("New message belongs to current conversation {}, updating UI", conversationId);
// Update UI in a thread-safe manner using UI.access()
ui.access(() -> {
try {
@@ -1010,11 +1057,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// Re-render all messages with the new message included
renderMessages();
// Ensure scroll anchor exists and scroll to show new message
ensureScrollAnchor();
scrollToBottom();
log.info("Messages re-rendered with new message");
}
} catch (Exception e) {

View File

@@ -21,6 +21,6 @@ public interface AppUserRepository extends MongoRepository<AppUser, ObjectId> {
// Find AppUser by appCode for task completion notifications
java.util.Optional<AppUser> findByAppCode(String appCode);
// Custom query methods can be added here if needed
// List<AppUser> findByBezeichnung(String bezeichnung);
// Find AppUser by bezeichnung
AppUser findByBezeichnung(String bezeichnung);
}

View File

@@ -0,0 +1,77 @@
package de.assecutor.votianlt.service;
import com.vaadin.flow.shared.Registration;
import de.assecutor.votianlt.event.MessageReadStatusChangedEvent;
import de.assecutor.votianlt.event.MessageReceivedEvent;
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;
/**
* Service that listens for message-related events and notifies registered UI components
* to update their message badges (e.g., in the sidebar navigation)
*/
@Service
@Slf4j
public class MessageBadgeUpdateService {
private final Executor executor = Executors.newSingleThreadExecutor();
private final LinkedHashSet<Runnable> listeners = new LinkedHashSet<>();
/**
* Register a listener that will be called when message badge should be updated
*
* @param listener Runnable that will be called when badge update is needed
* @return Registration object that can be used to unregister the listener
*/
public synchronized Registration register(Runnable listener) {
listeners.add(listener);
log.debug("Registered badge update listener. Total listeners: {}", listeners.size());
return () -> {
synchronized (MessageBadgeUpdateService.this) {
listeners.remove(listener);
log.debug("Unregistered badge update listener. Total listeners: {}", listeners.size());
}
};
}
/**
* Notify all registered listeners that badge should be updated
*/
private synchronized void notifyListeners() {
log.debug("Notifying {} badge update listeners", listeners.size());
for (Runnable listener : listeners) {
executor.execute(() -> {
try {
listener.run();
} catch (Exception e) {
log.error("Error notifying badge update listener", e);
}
});
}
}
/**
* Spring event listener for message read status changes
*/
@EventListener
public void onMessageReadStatusChanged(MessageReadStatusChangedEvent event) {
log.debug("MessageBadgeUpdateService received MessageReadStatusChangedEvent");
notifyListeners();
}
/**
* Spring event listener for new messages received
*/
@EventListener
public void onMessageReceived(MessageReceivedEvent event) {
log.debug("MessageBadgeUpdateService received MessageReceivedEvent");
notifyListeners();
}
}