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