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.Job;
import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.pages.service.AppUserService; 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.AppUserRepository;
import de.assecutor.votianlt.repository.CargoItemRepository; import de.assecutor.votianlt.repository.CargoItemRepository;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
@@ -49,6 +50,9 @@ public class MessageController {
// Map to store userId -> clientId mapping for active sessions // Map to store userId -> clientId mapping for active sessions
private final Map<String, String> userClientIdMapping = new ConcurrentHashMap<>(); 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 MqttPublisher mqttPublisher;
private final AppUserRepository appUserRepository; private final AppUserRepository appUserRepository;
@@ -67,12 +71,13 @@ public class MessageController {
private final JobHistoryService jobHistoryService; private final JobHistoryService jobHistoryService;
private final EmailService emailService; private final EmailService emailService;
private final MessageService messageService; private final MessageService messageService;
private final UserService userService;
public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository, public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository,
AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository, AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService, SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService,
EmailService emailService, MessageService messageService) { EmailService emailService, MessageService messageService, UserService userService) {
this.mqttPublisher = mqttPublisher; this.mqttPublisher = mqttPublisher;
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.appUserService = appUserService; this.appUserService = appUserService;
@@ -86,6 +91,7 @@ public class MessageController {
this.jobHistoryService = jobHistoryService; this.jobHistoryService = jobHistoryService;
this.emailService = emailService; this.emailService = emailService;
this.messageService = messageService; this.messageService = messageService;
this.userService = userService;
} }
/** /**
@@ -585,7 +591,8 @@ public class MessageController {
*/ */
private void storeClientIdMapping(String userId, String clientId) { private void storeClientIdMapping(String userId, String clientId) {
userClientIdMapping.put(userId, 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); 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. * Handle incoming message from a client via MQTT.
* Client sends to /server/{clientId}/message with payload: * Client sends to /server/{clientId}/message with payload:
@@ -604,21 +618,83 @@ public class MessageController {
* "content": "message payload", * "content": "message payload",
* "contentType": "TEXT|IMAGE", * "contentType": "TEXT|IMAGE",
* "jobId": "optional job id", * "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) { public void handleIncomingMessage(Map<String, Object> payload) {
log.info("MQTT Endpoint '/server/{clientId}/message' called with data: {}", payload); log.info("MQTT Endpoint '/server/{clientId}/message' called with data: {}", payload);
try { try {
ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload); ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload);
messageService.receiveMessageFromClient(inboundPayload);
log.info("Successfully saved incoming message from {} to {}", inboundPayload.sender(), // Extract clientId from payload (added by MqttV5ClientManager from topic)
inboundPayload.receiver()); // 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) { } catch (IllegalArgumentException validationError) {
log.warn("Incoming chat message rejected: {}", validationError.getMessage()); log.warn("Incoming chat message rejected: {}", validationError.getMessage());
} catch (Exception e) { } catch (Exception e) {
log.error("Error handling incoming message: {}", e.getMessage(), 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); messageController.handleAppLogin(req);
} else if (topic.matches("/server/.+/message")) { } else if (topic.matches("/server/.+/message")) {
try { 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); messageController.handleIncomingMessage(payload);
} catch (Exception e) { } catch (Exception e) {
log.error("Error handling incoming message on {}: {}", topic, e.getMessage(), 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; 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.Component;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.applayout.AppLayout; import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.avatar.Avatar; 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.auth.AnonymousAllowed;
import com.vaadin.flow.server.menu.MenuConfiguration; import com.vaadin.flow.server.menu.MenuConfiguration;
import com.vaadin.flow.server.menu.MenuEntry; import com.vaadin.flow.server.menu.MenuEntry;
import com.vaadin.flow.shared.Registration;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.UserInvoiceData; import de.assecutor.votianlt.model.UserInvoiceData;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.pages.view.EditProfileView; import de.assecutor.votianlt.pages.view.EditProfileView;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.MessageBadgeUpdateService;
import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.MessageService;
import lombok.extern.slf4j.Slf4j;
import static com.vaadin.flow.theme.lumo.LumoUtility.*; import static com.vaadin.flow.theme.lumo.LumoUtility.*;
@@ -34,21 +39,27 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
@AnonymousAllowed @AnonymousAllowed
@Slf4j
@Layout("main") @Layout("main")
public final class MainLayout extends AppLayout { public final class MainLayout extends AppLayout {
private final SecurityService securityService; private final SecurityService securityService;
private final UserInvoiceDataService userInvoiceDataService; private final UserInvoiceDataService userInvoiceDataService;
private final MessageService messageService; private final MessageService messageService;
private final MessageBadgeUpdateService messageBadgeUpdateService;
private Div headerRef; private Div headerRef;
private Scroller navRef; private Scroller navRef;
private Component userMenuRef; 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.securityService = securityService;
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.messageService = messageService; this.messageService = messageService;
this.messageBadgeUpdateService = messageBadgeUpdateService;
setPrimarySection(Section.DRAWER); setPrimarySection(Section.DRAWER);
// Always build the drawer; keep references and toggle visibility on attach and // 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()); item = new SideNavItem(menuEntry.title(), menuEntry.path());
} }
if ("Nachrichten".equals(menuEntry.title())) { if ("Nachrichten".equals(menuEntry.title())) {
long unreadCount = resolveUnreadMessageCount(); messagesNavItem = item;
if (unreadCount > 0) { }
Span badge = new Span(String.valueOf(unreadCount)); return item;
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");
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() { private long resolveUnreadMessageCount() {
@@ -191,6 +222,7 @@ public final class MainLayout extends AppLayout {
try { try {
User currentUser = securityService.getCurrentDatabaseUser(); User currentUser = securityService.getCurrentDatabaseUser();
if (currentUser != null) { if (currentUser != null) {
String email = Optional.ofNullable(currentUser.getEmail()).map(String::trim).orElse(""); String email = Optional.ofNullable(currentUser.getEmail()).map(String::trim).orElse("");
if (!email.isBlank()) { if (!email.isBlank()) {
@@ -217,6 +249,7 @@ public final class MainLayout extends AppLayout {
for (String receiver : candidateReceivers) { for (String receiver : candidateReceivers) {
unread += messageService.getUnreadMessageCount(receiver); unread += messageService.getUnreadMessageCount(receiver);
} }
return unread; return unread;
} }
@@ -269,4 +302,28 @@ public final class MainLayout extends AppLayout {
return false; 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); 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) { public AppUser updateAppUser(AppUser appUser) {
// Hash the password if it's being updated and not empty // Hash the password if it's being updated and not empty
if (appUser.getPassword() != null && !appUser.getPassword().isEmpty()) { 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.model.User;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import org.bson.types.ObjectId;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -22,6 +23,10 @@ public class UserService {
return userRepository.findByEmail(email).orElse(null); return userRepository.findByEmail(email).orElse(null);
} }
public User findById(ObjectId id) {
return userRepository.findById(id).orElse(null);
}
public User save(User user) { public User save(User user) {
if (user.getPassword() != null && !user.getPassword().startsWith("$2a$")) { if (user.getPassword() != null && !user.getPassword().startsWith("$2a$")) {
// Passwort verschlüsseln, falls noch nicht verschlüsselt // 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.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 de.assecutor.votianlt.event.MessageReadStatusChangedEvent;
import org.springframework.context.ApplicationEventPublisher;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
@@ -75,6 +77,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private final MessageService messageService; private final MessageService messageService;
private final SecurityService securityService; private final SecurityService securityService;
private final MessageBroadcaster messageBroadcaster; private final MessageBroadcaster messageBroadcaster;
private final ApplicationEventPublisher eventPublisher;
private String participantKey; private String participantKey;
private String conversationId; private String conversationId;
@@ -95,15 +98,17 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private static final float JPEG_COMPRESSION_QUALITY = 0.8f; private static final float JPEG_COMPRESSION_QUALITY = 0.8f;
public MessageDetailsView(AppUserService appUserService, MessageService messageService, public MessageDetailsView(AppUserService appUserService, MessageService messageService,
SecurityService securityService, MessageBroadcaster messageBroadcaster) { SecurityService securityService, MessageBroadcaster messageBroadcaster,
ApplicationEventPublisher eventPublisher) {
this.appUserService = appUserService; this.appUserService = appUserService;
this.messageService = messageService; this.messageService = messageService;
this.securityService = securityService; this.securityService = securityService;
this.messageBroadcaster = messageBroadcaster; this.messageBroadcaster = messageBroadcaster;
this.eventPublisher = eventPublisher;
// Set height to 100% to prevent page from growing beyond viewport // Set height to 100% to prevent page from growing beyond viewport
setHeightFull(); setHeightFull();
// Create main layout with fixed positioning // Create main layout with fixed positioning
contentLayout = new VerticalLayout(); contentLayout = new VerticalLayout();
contentLayout.setPadding(true); contentLayout.setPadding(true);
@@ -119,7 +124,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
public void beforeEnter(BeforeEnterEvent event) { public void beforeEnter(BeforeEnterEvent event) {
// Extract route parameters from URL // Extract route parameters from URL
RouteParameters parameters = event.getRouteParameters(); RouteParameters parameters = event.getRouteParameters();
this.participantKey = parameters.get("clientId").orElse(null); this.participantKey = parameters.get("clientId").orElse(null);
this.conversationId = parameters.get("conversationId").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 // Store current messages for rendering and future updates
this.currentMessages = filteredMessages; this.currentMessages = filteredMessages;
// Reset scroll anchor for new container // Reset scroll anchor for new container
scrollAnchor = null; scrollAnchor = null;
// Create messages container // Create messages container
messagesContainer = new VerticalLayout(); messagesContainer = new VerticalLayout();
messagesContainer.setPadding(false); messagesContainer.setPadding(false);
@@ -190,10 +195,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
HorizontalLayout inputLayout = createMessageInputArea(); HorizontalLayout inputLayout = createMessageInputArea();
contentLayout.add(inputLayout); contentLayout.add(inputLayout);
// Render messages using Vaadin components // Render messages using Vaadin components
renderMessages(); renderMessages();
// Ensure scroll anchor exists and scroll to bottom // Ensure scroll anchor exists and scroll to bottom
ensureScrollAnchor(); ensureScrollAnchor();
scrollToBottom(); scrollToBottom();
@@ -210,22 +215,54 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// Reset anchor so a fresh one can be attached after re-rendering // Reset anchor so a fresh one can be attached after re-rendering
scrollAnchor = null; scrollAnchor = null;
messagesContainer.removeAll(); messagesContainer.removeAll();
LocalDate currentDate = null; LocalDate currentDate = null;
for (Message message : currentMessages) { for (Message message : currentMessages) {
LocalDateTime timestamp = resolveTimestamp(message); LocalDateTime timestamp = resolveTimestamp(message);
LocalDate messageDate = timestamp.toLocalDate(); LocalDate messageDate = timestamp.toLocalDate();
// Add date separator if date changed // Add date separator if date changed
if (currentDate == null || !currentDate.equals(messageDate)) { if (currentDate == null || !currentDate.equals(messageDate)) {
messagesContainer.add(createDateSeparator(messageDate)); messagesContainer.add(createDateSeparator(messageDate));
currentDate = messageDate; currentDate = messageDate;
} }
// Add message bubble // Add message bubble
messagesContainer.add(createMessageBubble(message, timestamp)); 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() { private void openImageUploadDialog() {
@@ -372,7 +409,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("text-align", "center") .set("text-align", "center")
.set("margin", "20px 0"); .set("margin", "20px 0");
separator.setWidthFull(); separator.setWidthFull();
Span dateLabel = new Span(date.format(DATE_FORMATTER)); Span dateLabel = new Span(date.format(DATE_FORMATTER));
dateLabel.getStyle() dateLabel.getStyle()
.set("background-color", "#d0d0d0") .set("background-color", "#d0d0d0")
@@ -382,7 +419,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("font-weight", "500") .set("font-weight", "500")
.set("color", "#333333") .set("color", "#333333")
.set("display", "inline-block"); .set("display", "inline-block");
separator.add(dateLabel); separator.add(dateLabel);
return separator; return separator;
} }
@@ -394,7 +431,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// Determine alignment based on message origin // Determine alignment based on message origin
// CLIENT origin = client messages (left), SERVER origin = server messages (right) // CLIENT origin = client messages (left), SERVER origin = server messages (right)
boolean isServerMessage = message.getOrigin() == MessageOrigin.SERVER; boolean isServerMessage = message.getOrigin() == MessageOrigin.SERVER;
// Container for the message (aligns left or right) // Container for the message (aligns left or right)
Div messageWrapper = new Div(); Div messageWrapper = new Div();
String alignment = isServerMessage ? "right" : "left"; 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("justify-content", isServerMessage ? "flex-end" : "flex-start")
.set("margin", "5px 0") .set("margin", "5px 0")
.set("width", "100%"); .set("width", "100%");
// Message bubble // Message bubble
Div bubble = new Div(); Div bubble = new Div();
bubble.getStyle() bubble.getStyle()
@@ -416,10 +453,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("word-wrap", "break-word") .set("word-wrap", "break-word")
.set("white-space", "pre-wrap") .set("white-space", "pre-wrap")
.set("text-align", alignment); .set("text-align", alignment);
// Message content component (text or media) // Message content component (text or media)
Component contentComponent = createContentComponent(message, alignment); Component contentComponent = createContentComponent(message, alignment);
// Timestamp // Timestamp
Span timeSpan = new Span(timestamp.format(TIME_FORMATTER)); Span timeSpan = new Span(timestamp.format(TIME_FORMATTER));
timeSpan.getStyle() timeSpan.getStyle()
@@ -427,10 +464,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
.set("color", isServerMessage ? "#666666" : "#999999") .set("color", isServerMessage ? "#666666" : "#999999")
.set("display", "block") .set("display", "block")
.set("text-align", alignment); .set("text-align", alignment);
bubble.add(contentComponent, timeSpan); bubble.add(contentComponent, timeSpan);
messageWrapper.add(bubble); messageWrapper.add(bubble);
return messageWrapper; return messageWrapper;
} }
@@ -692,25 +729,25 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private HorizontalLayout createHeaderLayout(String clientName, String conversationTitle) { private HorizontalLayout createHeaderLayout(String clientName, String conversationTitle) {
Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create()); Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create());
backButton.addClickListener(e -> UI.getCurrent().navigate("user-messages/" + participantKey)); backButton.addClickListener(e -> UI.getCurrent().navigate("user-messages/" + participantKey));
VerticalLayout titleLayout = new VerticalLayout(); VerticalLayout titleLayout = new VerticalLayout();
titleLayout.setPadding(false); titleLayout.setPadding(false);
titleLayout.setSpacing(false); titleLayout.setSpacing(false);
H2 title = new H2(clientName); H2 title = new H2(clientName);
title.getStyle().set("margin", "0"); title.getStyle().set("margin", "0");
Span subtitle = new Span(conversationTitle); Span subtitle = new Span(conversationTitle);
subtitle.getStyle().set("color", "#666666"); subtitle.getStyle().set("color", "#666666");
subtitle.getStyle().set("font-size", "14px"); subtitle.getStyle().set("font-size", "14px");
titleLayout.add(title, subtitle); titleLayout.add(title, subtitle);
HorizontalLayout layout = new HorizontalLayout(backButton, titleLayout); HorizontalLayout layout = new HorizontalLayout(backButton, titleLayout);
layout.setWidthFull(); layout.setWidthFull();
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER); layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
layout.setSpacing(true); layout.setSpacing(true);
return layout; return layout;
} }
@@ -778,6 +815,16 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
contentType); 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) Notification.show("Nachricht gesendet", 2000, Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS); .addThemeVariants(NotificationVariant.LUMO_SUCCESS);
@@ -939,12 +986,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
protected void onAttach(AttachEvent attachEvent) { protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent); super.onAttach(attachEvent);
UI ui = attachEvent.getUI(); UI ui = attachEvent.getUI();
// Register listener for incoming messages // Register listener for incoming messages
broadcasterRegistration = messageBroadcaster.register(message -> { broadcasterRegistration = messageBroadcaster.register(message -> {
handleIncomingMessage(ui, message); handleIncomingMessage(ui, message);
}); });
log.info("MessageDetailsView attached and listener registered for conversation: {}", conversationId); 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 // Check if message involves the current participant
boolean involvesParticipant = participantKey.equals(message.getSender()) boolean involvesParticipant = participantKey.equals(message.getSender())
|| participantKey.equals(message.getReceiver()); || participantKey.equals(message.getReceiver());
if (!involvesParticipant) { if (!involvesParticipant) {
log.debug("Message does not involve current participant, ignoring"); log.debug("Message does not involve current participant, ignoring");
return; return;
@@ -982,7 +1029,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// Check if message belongs to the current conversation // Check if message belongs to the current conversation
boolean belongsToConversation = false; boolean belongsToConversation = false;
if ("general".equalsIgnoreCase(conversationId)) { if ("general".equalsIgnoreCase(conversationId)) {
// General conversation: messages without job context // General conversation: messages without job context
belongsToConversation = message.getJobId() == null && message.getJobNumber() == null; 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); log.info("New message belongs to current conversation {}, updating UI", conversationId);
// Update UI in a thread-safe manner using UI.access() // Update UI in a thread-safe manner using UI.access()
ui.access(() -> { ui.access(() -> {
try { try {
@@ -1010,11 +1057,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// Re-render all messages with the new message included // Re-render all messages with the new message included
renderMessages(); renderMessages();
// Ensure scroll anchor exists and scroll to show new message // Ensure scroll anchor exists and scroll to show new message
ensureScrollAnchor(); ensureScrollAnchor();
scrollToBottom(); scrollToBottom();
log.info("Messages re-rendered with new message"); log.info("Messages re-rendered with new message");
} }
} catch (Exception e) { } catch (Exception e) {

View File

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