diff --git a/backend/src/main/java/com/petshop/backend/dto/chat/ConversationResponse.java b/backend/src/main/java/com/petshop/backend/dto/chat/ConversationResponse.java index 86078ab2..cef833cd 100644 --- a/backend/src/main/java/com/petshop/backend/dto/chat/ConversationResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/chat/ConversationResponse.java @@ -11,6 +11,7 @@ public class ConversationResponse { private String status; private String mode; private String lastMessage; + private Long lastSenderId; private LocalDateTime humanRequestedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -18,19 +19,20 @@ public class ConversationResponse { public ConversationResponse() { } - public ConversationResponse(Long id, Long customerId, Long staffId, String status, String mode, String lastMessage, LocalDateTime humanRequestedAt, LocalDateTime createdAt, LocalDateTime updatedAt) { + public ConversationResponse(Long id, Long customerId, Long staffId, String status, String mode, String lastMessage, Long lastSenderId, LocalDateTime humanRequestedAt, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.customerId = customerId; this.staffId = staffId; this.status = status; this.mode = mode; this.lastMessage = lastMessage; + this.lastSenderId = lastSenderId; this.humanRequestedAt = humanRequestedAt; this.createdAt = createdAt; this.updatedAt = updatedAt; } - public static ConversationResponse fromEntity(Conversation conversation, String lastMessage) { + public static ConversationResponse fromEntity(Conversation conversation, String lastMessage, Long lastSenderId) { ConversationResponse response = new ConversationResponse(); response.setId(conversation.getId()); response.setCustomerId(conversation.getCustomerId()); @@ -38,12 +40,21 @@ public class ConversationResponse { response.setStatus(conversation.getStatus().name()); response.setMode(conversation.getMode().name()); response.setLastMessage(lastMessage); + response.setLastSenderId(lastSenderId); response.setHumanRequestedAt(conversation.getHumanRequestedAt()); response.setCreatedAt(conversation.getCreatedAt()); response.setUpdatedAt(conversation.getUpdatedAt()); return response; } + public Long getLastSenderId() { + return lastSenderId; + } + + public void setLastSenderId(Long lastSenderId) { + this.lastSenderId = lastSenderId; + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java b/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java index 2c39e8f1..ce0d413b 100644 --- a/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java @@ -8,6 +8,7 @@ public class MessageResponse { private Long id; private Long conversationId; private Long senderId; + private String senderAvatarUrl; private String content; private LocalDateTime timestamp; private Boolean isRead; @@ -19,10 +20,11 @@ public class MessageResponse { public MessageResponse() { } - public MessageResponse(Long id, Long conversationId, Long senderId, String content, LocalDateTime timestamp, Boolean isRead) { + public MessageResponse(Long id, Long conversationId, Long senderId, String senderAvatarUrl, String content, LocalDateTime timestamp, Boolean isRead) { this.id = id; this.conversationId = conversationId; this.senderId = senderId; + this.senderAvatarUrl = senderAvatarUrl; this.content = content; this.timestamp = timestamp; this.isRead = isRead; @@ -48,6 +50,14 @@ public class MessageResponse { return response; } + public String getSenderAvatarUrl() { + return senderAvatarUrl; + } + + public void setSenderAvatarUrl(String senderAvatarUrl) { + this.senderAvatarUrl = senderAvatarUrl; + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java b/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java index b5b808c2..d6b9d380 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java @@ -41,8 +41,10 @@ public class ChatRealtimeService { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); List messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); - String lastMessage = messages.isEmpty() ? "" : messages.get(messages.size() - 1).getContent(); - ConversationResponse response = ConversationResponse.fromEntity(conversation, lastMessage); + com.petshop.backend.entity.Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1); + String lastMessage = last != null && last.getContent() != null ? last.getContent() : ""; + Long lastSenderId = last != null ? last.getSenderId() : null; + ConversationResponse response = ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId); messagingTemplate.convertAndSend("/topic/chat/conversations", response); sendConversationToCustomerQueue(response); diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index 6ac092ce..41d2dfd6 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -28,15 +28,18 @@ public class ChatService { private final ConversationRepository conversationRepository; private final MessageRepository messageRepository; private final UserRepository userRepository; + private final AvatarStorageService avatarStorageService; private final ChatAttachmentStorageService attachmentStorageService; public ChatService(ConversationRepository conversationRepository, MessageRepository messageRepository, UserRepository userRepository, + AvatarStorageService avatarStorageService, ChatAttachmentStorageService attachmentStorageService) { this.conversationRepository = conversationRepository; this.messageRepository = messageRepository; this.userRepository = userRepository; + this.avatarStorageService = avatarStorageService; this.attachmentStorageService = attachmentStorageService; } @@ -62,7 +65,7 @@ public class ChatService { message.setIsRead(false); messageRepository.save(message); - return ConversationResponse.fromEntity(conversation, request.getMessage()); + return ConversationResponse.fromEntity(conversation, request.getMessage(), userId); } public List getConversations(Long userId, User.Role role) { @@ -84,7 +87,8 @@ public class ChatService { List messages = messageRepository.findByConversationIdOrderByTimestampAsc(conv.getId()); Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1); String lastMessage = last != null && last.getContent() != null ? last.getContent() : ""; - return ConversationResponse.fromEntity(conv, lastMessage); + Long lastSenderId = last != null ? last.getSenderId() : null; + return ConversationResponse.fromEntity(conv, lastMessage, lastSenderId); }) .collect(Collectors.toList()); } @@ -106,7 +110,8 @@ public class ChatService { Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1); String lastMessage = last != null && last.getContent() != null ? last.getContent() : ""; - return ConversationResponse.fromEntity(conversation, lastMessage); + Long lastSenderId = last != null ? last.getSenderId() : null; + return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId); } @Transactional @@ -147,7 +152,7 @@ public class ChatService { conversationRepository.save(conversation); } - return MessageResponse.fromEntity(message); + return toMessageResponse(message); } @Transactional @@ -190,7 +195,7 @@ public class ChatService { conversationRepository.save(conversation); } - return MessageResponse.fromEntity(message); + return toMessageResponse(message); } catch (IOException e) { throw new RuntimeException("Failed to store attachment", e); } @@ -217,7 +222,8 @@ public class ChatService { List messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1); String lastMessage = last != null && last.getContent() != null ? last.getContent() : ""; - return ConversationResponse.fromEntity(conversation, lastMessage); + Long lastSenderId = last != null ? last.getSenderId() : null; + return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId); } @Transactional @@ -240,7 +246,8 @@ public class ChatService { List messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1); String lastMessage = last != null && last.getContent() != null ? last.getContent() : ""; - return ConversationResponse.fromEntity(conversation, lastMessage); + Long lastSenderId = last != null ? last.getSenderId() : null; + return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId); } public List getMessages(Long conversationId, Long userId, User.Role role) { @@ -258,10 +265,20 @@ public class ChatService { List messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); return messages.stream() - .map(MessageResponse::fromEntity) + .map(this::toMessageResponse) .collect(Collectors.toList()); } + private MessageResponse toMessageResponse(Message message) { + MessageResponse response = MessageResponse.fromEntity(message); + userRepository.findById(message.getSenderId()).ifPresent(user -> { + if (avatarStorageService.hasAvatar(user)) { + response.setSenderAvatarUrl(avatarStorageService.toOwnerAvatarUrl(user)); + } + }); + return response; + } + public boolean hasConversationAccess(Long conversationId, Long userId, User.Role role) { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java b/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java index fcb93c0c..0462fe6b 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java @@ -11,7 +11,9 @@ import java.net.http.WebSocket; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -39,6 +41,10 @@ public class ChatRealtimeClient implements WebSocket.Listener { private Consumer statusListener; private volatile String currentStatus = "Chat disconnected"; + private final Map globalConversations = new HashMap<>(); + private final List> notificationListeners = new ArrayList<>(); + private boolean lastNotificationState = false; + private ChatRealtimeClient() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) @@ -49,6 +55,59 @@ public class ChatRealtimeClient implements WebSocket.Listener { return INSTANCE; } + public void addNotificationListener(Consumer listener) { + synchronized (lock) { + notificationListeners.add(listener); + listener.accept(lastNotificationState); + } + } + + public void initializeState(List conversations) { + synchronized (lock) { + globalConversations.clear(); + for (ConversationResponse conv : conversations) { + globalConversations.put(conv.getId(), conv); + } + } + updateNotificationState(); + } + + public boolean hasActionableChats() { + synchronized (lock) { + UserSession session = UserSession.getInstance(); + Long currentUserId = session.getUserId(); + for (ConversationResponse conv : globalConversations.values()) { + if ("CLOSED".equals(conv.getStatus())) continue; + + // Needs pickup + if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null) { + return true; + } + + // Needs reply (assigned to me and last sender was someone else - customer) + if (currentUserId != null && currentUserId.equals(conv.getStaffId())) { + if (conv.getLastSenderId() != null && !conv.getLastSenderId().equals(currentUserId)) { + return true; + } + } + } + return false; + } + } + + private void updateNotificationState() { + boolean currentState = hasActionableChats(); + List> listeners; + synchronized (lock) { + if (currentState == lastNotificationState) return; + lastNotificationState = currentState; + listeners = new ArrayList<>(notificationListeners); + } + for (Consumer listener : listeners) { + listener.accept(currentState); + } + } + public void setConversationListener(Consumer conversationListener) { this.conversationListener = conversationListener; } @@ -230,6 +289,8 @@ public class ChatRealtimeClient implements WebSocket.Listener { destinationBySubscription.clear(); conversationsSubscriptionId = null; conversationMessagesSubscriptionId = null; + globalConversations.clear(); + updateNotificationState(); } private void handleFrame(String frame) { @@ -272,11 +333,25 @@ public class ChatRealtimeClient implements WebSocket.Listener { if (messageListener != null) { messageListener.accept(message); } + + // Also update globalConversation last sender if this is the active conversation + synchronized (lock) { + ConversationResponse conv = globalConversations.get(message.getConversationId()); + if (conv != null) { + conv.setLastMessage(message.getContent()); + conv.setLastSenderId(message.getSenderId()); + } + } + updateNotificationState(); } else { ConversationResponse conversation = ApiClient.getInstance().getObjectMapper().readValue(bodyPart, ConversationResponse.class); + synchronized (lock) { + globalConversations.put(conversation.getId(), conversation); + } if (conversationListener != null) { conversationListener.accept(conversation); } + updateNotificationState(); } } catch (Exception e) { publishStatus("Chat update failed"); diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java index 1bcdba17..30a24fbb 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java @@ -9,6 +9,7 @@ public class ConversationResponse { private String status; private String mode; private String lastMessage; + private Long lastSenderId; private LocalDateTime humanRequestedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -16,6 +17,14 @@ public class ConversationResponse { public ConversationResponse() { } + public Long getLastSenderId() { + return lastSenderId; + } + + public void setLastSenderId(Long lastSenderId) { + this.lastSenderId = lastSenderId; + } + public Long getId() { return id; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageRequest.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageRequest.java index cd8efedf..a5c17ca4 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageRequest.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageRequest.java @@ -2,10 +2,6 @@ package org.example.petshopdesktop.api.dto.chat; public class MessageRequest { private String content; - private String attachmentUrl; - private String attachmentName; - private String attachmentMimeType; - private Long attachmentSizeBytes; public MessageRequest() { } @@ -21,36 +17,4 @@ public class MessageRequest { public void setContent(String content) { this.content = content; } - - public String getAttachmentUrl() { - return attachmentUrl; - } - - public void setAttachmentUrl(String attachmentUrl) { - this.attachmentUrl = attachmentUrl; - } - - public String getAttachmentName() { - return attachmentName; - } - - public void setAttachmentName(String attachmentName) { - this.attachmentName = attachmentName; - } - - public String getAttachmentMimeType() { - return attachmentMimeType; - } - - public void setAttachmentMimeType(String attachmentMimeType) { - this.attachmentMimeType = attachmentMimeType; - } - - public Long getAttachmentSizeBytes() { - return attachmentSizeBytes; - } - - public void setAttachmentSizeBytes(Long attachmentSizeBytes) { - this.attachmentSizeBytes = attachmentSizeBytes; - } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java index 096a6e58..6d5c6407 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java @@ -6,13 +6,10 @@ public class MessageResponse { private Long id; private Long conversationId; private Long senderId; + private String senderAvatarUrl; private String content; private LocalDateTime timestamp; private Boolean isRead; - private String attachmentUrl; - private String attachmentName; - private String attachmentMimeType; - private Long attachmentSizeBytes; public MessageResponse() { } @@ -41,6 +38,14 @@ public class MessageResponse { this.senderId = senderId; } + public String getSenderAvatarUrl() { + return senderAvatarUrl; + } + + public void setSenderAvatarUrl(String senderAvatarUrl) { + this.senderAvatarUrl = senderAvatarUrl; + } + public String getContent() { return content; } @@ -64,36 +69,4 @@ public class MessageResponse { public void setIsRead(Boolean isRead) { this.isRead = isRead; } - - public String getAttachmentUrl() { - return attachmentUrl; - } - - public void setAttachmentUrl(String attachmentUrl) { - this.attachmentUrl = attachmentUrl; - } - - public String getAttachmentName() { - return attachmentName; - } - - public void setAttachmentName(String attachmentName) { - this.attachmentName = attachmentName; - } - - public String getAttachmentMimeType() { - return attachmentMimeType; - } - - public void setAttachmentMimeType(String attachmentMimeType) { - this.attachmentMimeType = attachmentMimeType; - } - - public Long getAttachmentSizeBytes() { - return attachmentSizeBytes; - } - - public void setAttachmentSizeBytes(Long attachmentSizeBytes) { - this.attachmentSizeBytes = attachmentSizeBytes; - } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java index f6429731..3fcb7372 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java @@ -6,7 +6,6 @@ import org.example.petshopdesktop.api.dto.chat.ConversationRequest; import org.example.petshopdesktop.api.dto.chat.ConversationResponse; import org.example.petshopdesktop.api.dto.chat.MessageRequest; import org.example.petshopdesktop.api.dto.chat.MessageResponse; - import java.util.List; public class ChatApi { diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java index ed60335d..02d05a93 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java @@ -3,6 +3,7 @@ package org.example.petshopdesktop.controllers; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; +import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.Label; @@ -15,6 +16,9 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; +import javafx.scene.paint.ImagePattern; +import javafx.scene.shape.Circle; +import javafx.scene.image.Image; import org.example.petshopdesktop.api.ChatRealtimeClient; import org.example.petshopdesktop.api.dto.chat.ConversationResponse; import org.example.petshopdesktop.api.dto.chat.MessageRequest; @@ -25,6 +29,7 @@ import org.example.petshopdesktop.api.endpoints.DropdownApi; import org.example.petshopdesktop.auth.UserSession; import org.example.petshopdesktop.util.ActivityLogger; +import java.io.File; import java.time.format.DateTimeFormatter; import java.util.Comparator; import java.util.HashMap; @@ -53,6 +58,9 @@ public class ChatController { @FXML private Button btnRefresh; + @FXML + private Button btnAttachment; + @FXML private Label lblConversationTitle; @@ -63,6 +71,7 @@ public class ChatController { private final Map customerLabels = new HashMap<>(); private final ChatRealtimeClient realtimeClient = ChatRealtimeClient.getInstance(); private ConversationResponse selectedConversation; + private File selectedAttachmentFile; @FXML public void initialize() { @@ -78,7 +87,19 @@ public class ChatController { } Label title = new Label(getConversationTitle(item)); - title.setStyle("-fx-font-weight: bold; -fx-text-fill: #1f2937;"); + + // Bold title if needs attention + UserSession session = UserSession.getInstance(); + Long currentUserId = session.getUserId(); + boolean needsPickup = item.getHumanRequestedAt() != null && item.getStaffId() == null; + boolean needsReply = currentUserId != null && currentUserId.equals(item.getStaffId()) + && item.getLastSenderId() != null && !item.getLastSenderId().equals(currentUserId); + + if (needsPickup || needsReply) { + title.setStyle("-fx-font-weight: bold; -fx-text-fill: #FF6B6B;"); + } else { + title.setStyle("-fx-font-weight: bold; -fx-text-fill: #1f2937;"); + } Label preview = new Label(item.getLastMessage() == null ? "" : item.getLastMessage()); preview.setStyle("-fx-text-fill: #64748b;"); preview.setWrapText(true); @@ -135,13 +156,62 @@ public class ChatController { String content = txtMessage.getText() == null ? "" : txtMessage.getText().trim(); if (content.isEmpty()) { + if (selectedAttachmentFile != null) { + lblChatStatus.setText("Attachments are not available yet"); + } return; } + Long convId = selectedConversation.getId(); + txtMessage.clear(); btnSend.setDisable(true); + lblChatStatus.setText("Sending message..."); - sendMessage(selectedConversation.getId(), content); + + new Thread(() -> { + try { + MessageRequest request = new MessageRequest(content); + MessageResponse response = ChatApi.getInstance().sendMessage(convId, request); + Platform.runLater(() -> { + btnSend.setDisable(false); + appendMessageIfSelected(response); + if (selectedAttachmentFile != null) { + clearLocalAttachment(); + lblChatStatus.setText("Message sent without attachment"); + } else { + lblChatStatus.setText("Message sent"); + } + }); + } catch (Exception e) { + Platform.runLater(() -> { + txtMessage.setText(content); + btnSend.setDisable(false); + lblChatStatus.setText("Chat send failed"); + ActivityLogger.getInstance().logException( + "ChatController.sendMessage", + e, + "Sending chat message for conversation " + convId); + }); + } + }).start(); + } + + @FXML + void btnAttachmentClicked(ActionEvent event) { + File file = org.example.petshopdesktop.util.FilePickerSupport.pickAnyFile(btnAttachment.getScene().getWindow()); + if (file == null) return; + + selectedAttachmentFile = file; + btnAttachment.setText("📎 " + file.getName()); + btnAttachment.setStyle("-fx-background-color: #dcfce7; -fx-background-radius: 12; -fx-text-fill: #166534; -fx-cursor: hand;"); + lblChatStatus.setText("Attachment selected"); + } + + private void clearLocalAttachment() { + selectedAttachmentFile = null; + btnAttachment.setText("📎"); + btnAttachment.setStyle("-fx-background-color: #e2e8f0; -fx-background-radius: 12; -fx-text-fill: #475569; -fx-cursor: hand;"); } private void loadCustomers() { @@ -207,31 +277,6 @@ public class ChatController { }).start(); } - private void sendMessage(Long conversationId, String content) { - new Thread(() -> { - try { - MessageResponse response = ChatApi.getInstance().sendMessage(conversationId, new MessageRequest(content)); - Platform.runLater(() -> { - btnSend.setDisable(false); - appendMessageIfSelected(response); - if (selectedConversation != null && selectedConversation.getId().equals(conversationId)) { - lblChatStatus.setText("Message sent"); - } - }); - } catch (Exception e) { - Platform.runLater(() -> { - txtMessage.setText(content); - btnSend.setDisable(false); - lblChatStatus.setText("Chat send failed"); - ActivityLogger.getInstance().logException( - "ChatController.sendMessage", - e, - "Sending chat message for conversation " + conversationId); - }); - } - }).start(); - } - private void renderMessages(List messages) { vbMessages.getChildren().clear(); for (MessageResponse message : messages) { @@ -307,6 +352,20 @@ public class ChatController { private HBox createMessageBubble(MessageResponse message) { boolean mine = message.getSenderId() != null && message.getSenderId().equals(UserSession.getInstance().getUserId()); + + Circle avatar = new Circle(16); + avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1")); + if (message.getSenderAvatarUrl() != null && !message.getSenderAvatarUrl().isBlank()) { + try { + String fullUrl = org.example.petshopdesktop.api.ApiConfig.getInstance().getBaseUrl() + message.getSenderAvatarUrl(); + Image img = new Image(fullUrl, true); + img.errorProperty().addListener((obs, old, err) -> { + if (err) avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1")); + }); + avatar.setFill(new ImagePattern(img)); + } catch (Exception ignored) {} + } + Label author = new Label(resolveAuthorLabel(message)); author.setStyle("-fx-font-weight: bold; -fx-text-fill: " + (mine ? "#ffffff" : "#1f2937") + ";"); @@ -323,20 +382,6 @@ public class ChatController { bubble.getChildren().add(content); } - if (message.getAttachmentUrl() != null && !message.getAttachmentUrl().isBlank()) { - String attachmentLabel = message.getAttachmentName(); - if (attachmentLabel == null || attachmentLabel.isBlank()) { - attachmentLabel = "Attachment"; - } - if (message.getAttachmentSizeBytes() != null && message.getAttachmentSizeBytes() > 0) { - attachmentLabel = attachmentLabel + " (" + formatSize(message.getAttachmentSizeBytes()) + ")"; - } - Label attachment = new Label(attachmentLabel); - attachment.setWrapText(true); - attachment.setStyle("-fx-text-fill: " + (mine ? "#dbeafe" : "#0f766e") + "; -fx-underline: true;"); - bubble.getChildren().add(attachment); - } - bubble.getChildren().add(timestamp); bubble.setMaxWidth(420); bubble.setStyle(mine @@ -346,10 +391,13 @@ public class ChatController { Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); HBox container = new HBox(12); + container.setAlignment(javafx.geometry.Pos.BOTTOM_LEFT); + if (mine) { - container.getChildren().addAll(spacer, bubble); + container.getChildren().addAll(spacer, bubble, avatar); + container.setAlignment(javafx.geometry.Pos.BOTTOM_RIGHT); } else { - container.getChildren().addAll(bubble, spacer); + container.getChildren().addAll(avatar, bubble, spacer); } return container; } @@ -392,17 +440,4 @@ public class ChatController { private void scrollMessagesToBottom() { Platform.runLater(() -> spMessages.setVvalue(1.0)); } - private String formatSize(Long bytes) { - if (bytes == null || bytes <= 0) { - return ""; - } - double size = bytes; - String[] units = {"B", "KB", "MB", "GB"}; - int unitIndex = 0; - while (size >= 1024 && unitIndex < units.length - 1) { - size = size / 1024; - unitIndex++; - } - return unitIndex == 0 ? String.format("%.0f %s", size, units[unitIndex]) : String.format("%.1f %s", size, units[unitIndex]); - } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java index 1b10ce19..69916845 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java @@ -17,6 +17,7 @@ import javafx.scene.paint.Color; import javafx.scene.paint.ImagePattern; import javafx.scene.shape.Circle; import javafx.stage.Stage; +import org.example.petshopdesktop.api.endpoints.ChatApi; import org.example.petshopdesktop.api.ChatRealtimeClient; import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse; import org.example.petshopdesktop.api.dto.auth.UserInfoResponse; @@ -55,7 +56,10 @@ public class MainLayoutController { private Button btnInventory; @FXML - private Button btnLogout; + private Button btnChat; + + @FXML + private Circle chatBadge; @FXML private Button btnPets; @@ -85,7 +89,7 @@ public class MainLayoutController { private Button btnAnalytics; @FXML - private Button btnChat; + private Button btnLogout; @FXML private StackPane logoContainer; @@ -263,6 +267,14 @@ public class MainLayoutController { refreshProfileHeader(); applyRBAC(); + ChatRealtimeClient.getInstance().addNotificationListener(hasActionable -> { + Platform.runLater(() -> { + if (chatBadge != null) { + chatBadge.setVisible(hasActionable); + } + }); + }); + UserSession session = UserSession.getInstance(); if (session.isAdmin()) { loadView("analytics-view.fxml"); @@ -391,7 +403,16 @@ public class MainLayoutController { btnSalesHistory.setText(isAdmin ? "Sales History" : "Sales"); - + // Initial chat state and subscription + new Thread(() -> { + try { + var conversations = ChatApi.getInstance().listConversations(); + ChatRealtimeClient.getInstance().initializeState(conversations); + ChatRealtimeClient.getInstance().subscribeToConversations(); + } catch (Exception e) { + ActivityLogger.getInstance().logException("MainLayoutController.applyRBAC", e, "Initializing chat notifications"); + } + }).start(); } private void loadView(String fxmlFile) { diff --git a/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java b/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java index 313c78e9..b81c764a 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java +++ b/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java @@ -24,6 +24,13 @@ public final class FilePickerSupport { return pickImageFileWithJavaFx(ownerWindow); } + public static File pickAnyFile(Window ownerWindow) { + if (shouldUseAwtPicker()) { + return pickAnyFileWithSwing(); + } + return pickAnyFileWithJavaFx(ownerWindow); + } + private static boolean shouldUseAwtPicker() { if (GraphicsEnvironment.isHeadless()) { return false; @@ -40,6 +47,12 @@ public final class FilePickerSupport { return chooser.showOpenDialog(ownerWindow); } + private static File pickAnyFileWithJavaFx(Window ownerWindow) { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Choose File Attachment"); + return chooser.showOpenDialog(ownerWindow); + } + private static File pickImageFileWithSwing() { AtomicReference selectedFile = new AtomicReference<>(); Runnable dialogTask = () -> { @@ -74,4 +87,38 @@ public final class FilePickerSupport { return selectedFile.get(); } + + private static File pickAnyFileWithSwing() { + AtomicReference selectedFile = new AtomicReference<>(); + Runnable dialogTask = () -> { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception ignored) { + } + + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Choose File Attachment"); + chooser.setAcceptAllFileFilterUsed(true); + + int result = chooser.showOpenDialog((Component) null); + if (result == JFileChooser.APPROVE_OPTION) { + selectedFile.set(chooser.getSelectedFile()); + } + }; + + try { + if (java.awt.EventQueue.isDispatchThread()) { + dialogTask.run(); + } else { + java.awt.EventQueue.invokeAndWait(dialogTask); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return null; + } catch (InvocationTargetException ex) { + throw new IllegalStateException("Failed to open Swing file picker", ex.getCause()); + } + + return selectedFile.get(); + } } diff --git a/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml index b7b209c5..984aad76 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml @@ -2,6 +2,8 @@ + + @@ -123,14 +125,21 @@ - + + + + + + + + diff --git a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml index e425ec67..b55fd5c9 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml @@ -61,8 +61,16 @@