Implement chat features

This commit is contained in:
2026-04-09 21:48:39 -06:00
parent 4bd44727a5
commit 6b055c4364
16 changed files with 518 additions and 113 deletions

View File

@@ -0,0 +1,50 @@
package com.petshop.backend.controller;
import com.petshop.backend.service.ChatAttachmentService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/chat/attachments")
public class ChatAttachmentController {
private final ChatAttachmentService attachmentService;
public ChatAttachmentController(ChatAttachmentService attachmentService) {
this.attachmentService = attachmentService;
}
@PostMapping
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<?> uploadAttachment(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "File is empty"));
}
try {
return ResponseEntity.ok(attachmentService.storeAttachment(file));
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Failed to store attachment: " + e.getMessage()));
}
}
@GetMapping("/{filename}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<Resource> getAttachment(@PathVariable String filename) {
try {
Resource resource = attachmentService.loadAttachmentResource(filename);
return ResponseEntity.ok()
.contentType(attachmentService.resolveMediaType(filename))
.body(resource);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,56 @@
package com.petshop.backend.service;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
@Service
public class ChatAttachmentService {
private static final String STORED_PREFIX = "/uploads/chat/";
private final Path chatDirectory = Paths.get("uploads", "chat").toAbsolutePath().normalize();
public record AttachmentMetadata(String url, String fileName, String mimeType, long size) {}
public AttachmentMetadata storeAttachment(MultipartFile file) throws IOException {
Files.createDirectories(chatDirectory);
String originalFilename = file.getOriginalFilename();
String extension = resolveExtension(originalFilename);
String filename = UUID.randomUUID() + extension;
Path filePath = chatDirectory.resolve(filename).normalize();
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
String url = "/api/v1/chat/attachments/" + filename;
return new AttachmentMetadata(url, originalFilename, file.getContentType(), file.getSize());
}
public Resource loadAttachmentResource(String filename) {
Path filePath = chatDirectory.resolve(filename).normalize();
if (!filePath.startsWith(chatDirectory) || !Files.exists(filePath) || !Files.isRegularFile(filePath)) {
throw new IllegalArgumentException("Attachment file was not found");
}
return new PathResource(filePath);
}
public MediaType resolveMediaType(String filename) {
return MediaTypeFactory.getMediaType(filename).orElse(MediaType.APPLICATION_OCTET_STREAM);
}
private String resolveExtension(String originalFilename) {
if (originalFilename == null) return "";
int idx = originalFilename.lastIndexOf('.');
return idx < 0 ? "" : originalFilename.substring(idx);
}
}

View File

@@ -41,8 +41,10 @@ public class ChatRealtimeService {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
List<com.petshop.backend.entity.Message> 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);

View File

@@ -15,9 +15,7 @@ import com.petshop.backend.repository.UserRepository;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@@ -28,16 +26,16 @@ public class ChatService {
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final UserRepository userRepository;
private final ChatAttachmentStorageService attachmentStorageService;
private final AvatarStorageService avatarStorageService;
public ChatService(ConversationRepository conversationRepository,
MessageRepository messageRepository,
UserRepository userRepository,
ChatAttachmentStorageService attachmentStorageService) {
AvatarStorageService avatarStorageService) {
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.userRepository = userRepository;
this.attachmentStorageService = attachmentStorageService;
this.avatarStorageService = avatarStorageService;
}
@Transactional
@@ -62,7 +60,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<ConversationResponse> getConversations(Long userId, User.Role role) {
@@ -84,7 +82,8 @@ public class ChatService {
List<Message> 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());
}
@@ -105,8 +104,9 @@ public class ChatService {
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
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;
return ConversationResponse.fromEntity(conversation, lastMessage);
return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId);
}
@Transactional
@@ -147,53 +147,7 @@ public class ChatService {
conversationRepository.save(conversation);
}
return MessageResponse.fromEntity(message);
}
@Transactional
public MessageResponse sendMessageWithAttachment(Long conversationId, Long userId, User.Role role, MultipartFile file, String content) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) {
throw new AccessDeniedException("Conversation is closed");
}
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only send messages to your own conversations");
}
if (role == User.Role.STAFF) {
throw new AccessDeniedException("You can only reply to conversations assigned to you or unassigned conversations");
}
}
try {
String attachmentUrl = attachmentStorageService.storeAttachment(file);
Message message = new Message();
message.setConversationId(conversationId);
message.setSenderId(userId);
message.setContent(content);
message.setAttachmentUrl(attachmentUrl);
message.setAttachmentName(file.getOriginalFilename());
message.setAttachmentMimeType(file.getContentType());
message.setAttachmentSizeBytes(file.getSize());
message.setIsRead(false);
message = messageRepository.save(message);
if (role == User.Role.STAFF && conversation.getStaffId() == null) {
conversation.setStaffId(userId);
}
if (role == User.Role.STAFF) {
conversation.setMode(Conversation.ConversationMode.HUMAN);
conversationRepository.save(conversation);
}
return MessageResponse.fromEntity(message);
} catch (IOException e) {
throw new RuntimeException("Failed to store attachment", e);
}
return toMessageResponse(message);
}
@Transactional
@@ -217,7 +171,8 @@ public class ChatService {
List<Message> 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 +195,8 @@ public class ChatService {
List<Message> 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<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) {
@@ -258,10 +214,20 @@ public class ChatService {
List<Message> 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"));

View File

@@ -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<String> statusListener;
private volatile String currentStatus = "Chat disconnected";
private final Map<Long, ConversationResponse> globalConversations = new HashMap<>();
private final List<Consumer<Boolean>> 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<Boolean> listener) {
synchronized (lock) {
notificationListeners.add(listener);
listener.accept(lastNotificationState);
}
}
public void initializeState(List<ConversationResponse> 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<Consumer<Boolean>> listeners;
synchronized (lock) {
if (currentState == lastNotificationState) return;
lastNotificationState = currentState;
listeners = new ArrayList<>(notificationListeners);
}
for (Consumer<Boolean> listener : listeners) {
listener.accept(currentState);
}
}
public void setConversationListener(Consumer<ConversationResponse> 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");

View File

@@ -0,0 +1,43 @@
package org.example.petshopdesktop.api.dto.chat;
public class ChatAttachmentResponse {
private String url;
private String fileName;
private String mimeType;
private long size;
public ChatAttachmentResponse() {
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
}

View File

@@ -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;
}

View File

@@ -6,6 +6,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;
@@ -41,6 +42,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;
}

View File

@@ -2,11 +2,13 @@ package org.example.petshopdesktop.api.endpoints;
import com.fasterxml.jackson.core.type.TypeReference;
import org.example.petshopdesktop.api.ApiClient;
import org.example.petshopdesktop.api.dto.chat.ChatAttachmentResponse;
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.nio.file.Path;
import java.util.List;
public class ChatApi {
@@ -42,4 +44,8 @@ public class ChatApi {
public MessageResponse sendMessage(Long conversationId, MessageRequest request) throws Exception {
return apiClient.post("/api/v1/chat/conversations/" + conversationId + "/messages", request, MessageResponse.class);
}
public ChatAttachmentResponse uploadAttachment(Path filePath) throws Exception {
return apiClient.postMultipart("/api/v1/chat/attachments", "file", filePath, ChatAttachmentResponse.class);
}
}

View File

@@ -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,7 +16,11 @@ 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.ChatAttachmentResponse;
import org.example.petshopdesktop.api.dto.chat.ConversationResponse;
import org.example.petshopdesktop.api.dto.chat.MessageRequest;
import org.example.petshopdesktop.api.dto.chat.MessageResponse;
@@ -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<Long, String> customerLabels = new HashMap<>();
private final ChatRealtimeClient realtimeClient = ChatRealtimeClient.getInstance();
private ConversationResponse selectedConversation;
private ChatAttachmentResponse selectedAttachment;
@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);
@@ -134,14 +155,78 @@ public class ChatController {
}
String content = txtMessage.getText() == null ? "" : txtMessage.getText().trim();
if (content.isEmpty()) {
if (content.isEmpty() && selectedAttachment == null) {
return;
}
Long convId = selectedConversation.getId();
var attachment = selectedAttachment;
txtMessage.clear();
btnSend.setDisable(true);
selectedAttachment = null;
btnAttachment.setText("📎");
btnAttachment.setStyle("-fx-background-color: #e2e8f0; -fx-background-radius: 12; -fx-text-fill: #475569; -fx-cursor: hand;");
lblChatStatus.setText("Sending message...");
sendMessage(selectedConversation.getId(), content);
new Thread(() -> {
try {
MessageRequest request = new MessageRequest(content);
if (attachment != null) {
request.setAttachmentUrl(attachment.getUrl());
request.setAttachmentName(attachment.getFileName());
request.setAttachmentMimeType(attachment.getMimeType());
request.setAttachmentSizeBytes(attachment.getSize());
}
MessageResponse response = ChatApi.getInstance().sendMessage(convId, request);
Platform.runLater(() -> {
btnSend.setDisable(false);
appendMessageIfSelected(response);
lblChatStatus.setText("Message sent");
});
} catch (Exception e) {
Platform.runLater(() -> {
txtMessage.setText(content);
btnSend.setDisable(false);
selectedAttachment = attachment;
btnAttachment.setText("📎 " + attachment.getFileName());
lblChatStatus.setText("Chat send failed");
ActivityLogger.getInstance().logException(
"ChatController.sendMessage",
e,
"Sending chat message for conversation " + convId);
});
}
}).start();
}
@FXML
void btnAttachmentClicked(ActionEvent event) {
java.io.File file = org.example.petshopdesktop.util.FilePickerSupport.pickAnyFile(btnAttachment.getScene().getWindow());
if (file == null) return;
btnAttachment.setDisable(true);
lblChatStatus.setText("Uploading attachment...");
new Thread(() -> {
try {
var response = ChatApi.getInstance().uploadAttachment(file.toPath());
Platform.runLater(() -> {
selectedAttachment = response;
btnAttachment.setText("📎 " + response.getFileName());
btnAttachment.setStyle("-fx-background-color: #dcfce7; -fx-background-radius: 12; -fx-text-fill: #166534; -fx-cursor: hand;");
lblChatStatus.setText("File ready to send");
btnAttachment.setDisable(false);
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("ChatController.btnAttachmentClicked", e, "Uploading chat attachment");
Platform.runLater(() -> {
lblChatStatus.setText("Upload failed");
btnAttachment.setDisable(false);
});
}
}).start();
}
private void loadCustomers() {
@@ -207,31 +292,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<MessageResponse> messages) {
vbMessages.getChildren().clear();
for (MessageResponse message : messages) {
@@ -307,6 +367,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") + ";");
@@ -324,17 +398,23 @@ public class ChatController {
}
if (message.getAttachmentUrl() != null && !message.getAttachmentUrl().isBlank()) {
String attachmentLabel = message.getAttachmentName();
if (attachmentLabel == null || attachmentLabel.isBlank()) {
attachmentLabel = "Attachment";
}
String attachmentLabel = "📎 " + (message.getAttachmentName() == null || message.getAttachmentName().isBlank() ? "Attachment" : message.getAttachmentName());
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);
Button attachmentBtn = new Button(attachmentLabel);
attachmentBtn.setStyle("-fx-background-color: " + (mine ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.05)") + "; -fx-text-fill: " + (mine ? "#ffffff" : "#0f766e") + "; -fx-cursor: hand; -fx-background-radius: 8; -fx-padding: 6 10;");
attachmentBtn.setOnAction(e -> {
try {
String fullUrl = org.example.petshopdesktop.api.ApiConfig.getInstance().getBaseUrl() + message.getAttachmentUrl();
if (java.awt.Desktop.isDesktopSupported()) {
java.awt.Desktop.getDesktop().browse(new java.net.URI(fullUrl));
}
} catch (Exception ex) {
ActivityLogger.getInstance().logException("ChatController.attachmentOpen", ex, "Opening attachment URL");
}
});
bubble.getChildren().add(attachmentBtn);
}
bubble.getChildren().add(timestamp);
@@ -346,10 +426,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;
}

View File

@@ -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) {

View File

@@ -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<File> selectedFile = new AtomicReference<>();
Runnable dialogTask = () -> {
@@ -74,4 +87,38 @@ public final class FilePickerSupport {
return selectedFile.get();
}
private static File pickAnyFileWithSwing() {
AtomicReference<File> 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();
}
}

View File

@@ -2,6 +2,8 @@
<?import javafx.geometry.Insets?>
<?import javafx.scene.effect.DropShadow?>
<?import javafx.scene.layout.StackPane?>
<?import javafx.scene.shape.Circle?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ScrollPane?>
@@ -123,14 +125,21 @@
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
</padding>
</Button>
<Button fx:id="btnChat" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnChatClicked" style="-fx-background-color: transparent; -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Chat" textFill="#cbd5e1">
<font>
<Font name="System" size="12.0" />
</font>
<padding>
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
</padding>
</Button>
<StackPane alignment="CENTER_LEFT">
<Button fx:id="btnChat" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnChatClicked" style="-fx-background-color: transparent; -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Chat" textFill="#cbd5e1">
<font>
<Font name="System" size="12.0" />
</font>
<padding>
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
</padding>
</Button>
<javafx.scene.shape.Circle fx:id="chatBadge" fill="#ff4d4d" radius="4.0" visible="false" StackPane.alignment="CENTER_LEFT">
<StackPane.margin>
<Insets left="40.0" top="-8.0" />
</StackPane.margin>
</javafx.scene.shape.Circle>
</StackPane>
<Separator prefWidth="200.0" style="-fx-background-color: #444444; -fx-opacity: 0.35;" />

View File

@@ -61,8 +61,16 @@
<VBox spacing="10.0" style="-fx-background-color: #ffffff; -fx-background-radius: 16; -fx-padding: 14;">
<children>
<TextArea fx:id="txtMessage" prefRowCount="4" promptText="Reply to the selected conversation..." wrapText="true" />
<HBox alignment="CENTER_RIGHT">
<HBox alignment="CENTER_RIGHT" spacing="10.0">
<children>
<Button fx:id="btnAttachment" mnemonicParsing="false" onAction="#btnAttachmentClicked" style="-fx-background-color: #e2e8f0; -fx-background-radius: 12; -fx-text-fill: #475569; -fx-cursor: hand;" text="📎">
<padding>
<Insets bottom="10.0" left="14.0" right="14.0" top="10.0" />
</padding>
<font>
<Font size="14.0" />
</font>
</Button>
<Button fx:id="btnSend" mnemonicParsing="false" onAction="#btnSendClicked" style="-fx-background-color: #1d4ed8; -fx-background-radius: 12; -fx-text-fill: white; -fx-cursor: hand;" text="Send Message">
<padding>
<Insets bottom="10.0" left="18.0" right="18.0" top="10.0" />