From e17cde6b87ad4a9b03224818c1845bcab40900ff Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Tue, 10 Mar 2026 20:04:32 -0600 Subject: [PATCH] Add desktop chat --- .gitignore | 1 + src/main/java/module-info.java | 3 +- .../example/petshopdesktop/Validator.class | Bin 2511 -> 0 bytes .../api/ChatRealtimeClient.java | 336 +++++++++++++++++ .../api/dto/chat/ConversationRequest.java | 20 + .../api/dto/chat/ConversationResponse.java | 72 ++++ .../api/dto/chat/MessageRequest.java | 20 + .../api/dto/chat/MessageResponse.java | 63 ++++ .../petshopdesktop/api/endpoints/ChatApi.java | 45 +++ .../example/petshopdesktop/auth/Role.class | Bin 995 -> 0 bytes .../controllers/ChatController.java | 341 ++++++++++++++++++ .../controllers/MainLayoutController.java | 2 + .../petshopdesktop/modelviews/chat-view.fxml | 82 ++++- 13 files changed, 973 insertions(+), 12 deletions(-) delete mode 100644 src/main/java/org/example/petshopdesktop/Validator.class create mode 100644 src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java create mode 100644 src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationRequest.java create mode 100644 src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java create mode 100644 src/main/java/org/example/petshopdesktop/api/dto/chat/MessageRequest.java create mode 100644 src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java create mode 100644 src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java delete mode 100644 src/main/java/org/example/petshopdesktop/auth/Role.class create mode 100644 src/main/java/org/example/petshopdesktop/controllers/ChatController.java diff --git a/.gitignore b/.gitignore index 3062bff3..a8b304b6 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ connectionpetstore.properties # Log files *.log log.txt +*.class diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index d946c327..ba43478b 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -25,6 +25,7 @@ module org.example.petshopdesktop { opens org.example.petshopdesktop.api.dto.inventory to com.fasterxml.jackson.databind; opens org.example.petshopdesktop.api.dto.appointment to com.fasterxml.jackson.databind; opens org.example.petshopdesktop.api.dto.adoption to com.fasterxml.jackson.databind; + opens org.example.petshopdesktop.api.dto.chat to com.fasterxml.jackson.databind; opens org.example.petshopdesktop.api.dto.sale to com.fasterxml.jackson.databind; opens org.example.petshopdesktop.api.dto.user to com.fasterxml.jackson.databind; opens org.example.petshopdesktop.api.dto.analytics to com.fasterxml.jackson.databind; @@ -33,4 +34,4 @@ module org.example.petshopdesktop { exports org.example.petshopdesktop; exports org.example.petshopdesktop.controllers; exports org.example.petshopdesktop.auth; -} \ No newline at end of file +} diff --git a/src/main/java/org/example/petshopdesktop/Validator.class b/src/main/java/org/example/petshopdesktop/Validator.class deleted file mode 100644 index b5653a2dca2e18266db187dfd0144479ca7ca7f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2511 zcma)8-B%Mw9KAzAHY5=+B7z^dDq6lm{H{Q2RgfBmh(eJnehgtsR>E$&yWrQMr>Cd? zL#^%UQ_u0$zStff`q+p5P1^R(ZX`s~p0bD8ot@v@-<|urGn+sE+V~y7$0#JwfQW%; z3XO;fv_6z;GFy`NQug}7LsjqvVjo$y<)0Ub^z_cg5f?y$SJFT#g&k{*e^YSgg{(!XW-MLv1Hw0%$DT+c)`dk8eL{Y~%oR|&LX zw}JK)_MoGN&X}{hP*MWPvUEN5uD82RqTaDIIMu*DfrC{V`PG#L<&Hbf~r(~W7%uYvdV@VDBNh{vOrOu2t40f|4Lv8dPUlc zCFONbIL`8Fxz4`Z;^&%)Gf&E@PWYWI@!?4_vei`;h*4Q8O8dYR zEmtiLnu$231h<7SS5`gWTu|nctE8`7)2GI!3PaCaG!E)D|NjJ8lZE! z{yUThMObbe!a1I4`Y+B?kY56}!|Wa4UGU!6_bbFNd=MC6tB1k!uL&PPADcS#n*NaR z-jOyY`~EG9prd7 zBMxI9j_^7fWIuq&TeLHKt>zR@9^btF{IJb&Ol-J)8zV^!VS z_yMh@G)~=e)a@$kb&a3dDMsW;?>a4WqpDjQ35b8u?IJQ2-L4Q!m%cr1=u}+grOj2|;3_q$HePx#- destinationBySubscription = new HashMap<>(); + private final Object lock = new Object(); + + private WebSocket webSocket; + private boolean connecting; + private boolean connected; + private boolean conversationsSubscribed; + private Long selectedConversationId; + private String conversationsSubscriptionId; + private String conversationMessagesSubscriptionId; + private Consumer conversationListener; + private Consumer messageListener; + private Consumer statusListener; + + private ChatRealtimeClient() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + public static ChatRealtimeClient getInstance() { + return INSTANCE; + } + + public void setConversationListener(Consumer conversationListener) { + this.conversationListener = conversationListener; + } + + public void setMessageListener(Consumer messageListener) { + this.messageListener = messageListener; + } + + public void setStatusListener(Consumer statusListener) { + this.statusListener = statusListener; + } + + public void connect() { + String token = UserSession.getInstance().getJwtToken(); + if (token == null || token.isBlank()) { + publishStatus("Chat disconnected"); + return; + } + + synchronized (lock) { + if (connected || connecting) { + return; + } + connecting = true; + } + + String wsUrl = ApiConfig.getInstance().getBaseUrl() + .replaceFirst("^http://", "ws://") + .replaceFirst("^https://", "wss://") + "/ws/chat"; + + publishStatus("Connecting chat..."); + + httpClient.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .buildAsync(URI.create(wsUrl), this) + .thenAccept(socket -> { + synchronized (lock) { + webSocket = socket; + } + socket.sendText("CONNECT\naccept-version:1.2\nhost:localhost\nAuthorization:Bearer " + token + "\n\n\0", true); + }) + .exceptionally(ex -> { + synchronized (lock) { + resetConnectionState(); + } + publishStatus("Chat unavailable"); + return null; + }); + } + + public void disconnect() { + WebSocket socket; + synchronized (lock) { + socket = webSocket; + resetConnectionState(); + selectedConversationId = null; + conversationsSubscribed = false; + } + if (socket != null) { + socket.sendText("DISCONNECT\n\n\0", true); + socket.sendClose(WebSocket.NORMAL_CLOSURE, "bye"); + } + publishStatus("Chat disconnected"); + } + + public void subscribeToConversations() { + synchronized (lock) { + conversationsSubscribed = true; + } + connect(); + synchronized (lock) { + if (connected && conversationsSubscriptionId == null) { + conversationsSubscriptionId = subscribeLocked("/topic/chat/conversations"); + } + } + } + + public void subscribeToConversation(Long conversationId) { + synchronized (lock) { + selectedConversationId = conversationId; + } + connect(); + synchronized (lock) { + if (connected) { + applySelectedConversationSubscriptionLocked(); + } + } + } + + public boolean isConnected() { + synchronized (lock) { + return connected; + } + } + + public boolean sendMessage(Long conversationId, String content) { + String token = UserSession.getInstance().getJwtToken(); + if (token == null || token.isBlank()) { + publishStatus("Chat send failed"); + return false; + } + String body; + try { + body = ApiClient.getInstance().getObjectMapper().writeValueAsString(new MessageRequest(content)); + } catch (Exception e) { + publishStatus("Chat send failed"); + return false; + } + + synchronized (lock) { + if (!connected || webSocket == null) { + connect(); + return false; + } + webSocket.sendText( + "SEND\ndestination:/app/chat/conversations/" + conversationId + "/messages\nAuthorization:Bearer " + token + "\ncontent-type:application/json\ncontent-length:" + body.getBytes(StandardCharsets.UTF_8).length + "\n\n" + body + "\0", + true + ); + return true; + } + } + + private String subscribeLocked(String destination) { + String subscriptionId = "sub-" + subscriptionCounter.getAndIncrement(); + destinationBySubscription.put(subscriptionId, destination); + webSocket.sendText("SUBSCRIBE\nid:" + subscriptionId + "\ndestination:" + destination + "\n\n\0", true); + return subscriptionId; + } + + private void unsubscribeLocked(String subscriptionId) { + destinationBySubscription.remove(subscriptionId); + if (webSocket != null) { + webSocket.sendText("UNSUBSCRIBE\nid:" + subscriptionId + "\n\n\0", true); + } + } + + private void applySubscriptionsLocked() { + if (webSocket == null || !connected) { + return; + } + if (conversationsSubscribed && conversationsSubscriptionId == null) { + conversationsSubscriptionId = subscribeLocked("/topic/chat/conversations"); + } + applySelectedConversationSubscriptionLocked(); + } + + private void applySelectedConversationSubscriptionLocked() { + if (webSocket == null || !connected) { + return; + } + + String destination = selectedConversationId == null ? null : "/topic/chat/conversations/" + selectedConversationId; + if (destination == null) { + if (conversationMessagesSubscriptionId != null) { + unsubscribeLocked(conversationMessagesSubscriptionId); + conversationMessagesSubscriptionId = null; + } + return; + } + + if (conversationMessagesSubscriptionId != null) { + String currentDestination = destinationBySubscription.get(conversationMessagesSubscriptionId); + if (destination.equals(currentDestination)) { + return; + } + unsubscribeLocked(conversationMessagesSubscriptionId); + } + + conversationMessagesSubscriptionId = subscribeLocked(destination); + } + + private void resetConnectionState() { + webSocket = null; + connecting = false; + connected = false; + destinationBySubscription.clear(); + conversationsSubscriptionId = null; + conversationMessagesSubscriptionId = null; + } + + private void handleFrame(String frame) { + String normalized = frame.replace("\r\n", "\n"); + int separator = normalized.indexOf("\n\n"); + String headerPart = separator >= 0 ? normalized.substring(0, separator) : normalized; + String bodyPart = separator >= 0 ? normalized.substring(separator + 2) : ""; + String[] headerLines = headerPart.split("\n"); + if (headerLines.length == 0) { + return; + } + + String command = headerLines[0]; + Map headers = new HashMap<>(); + for (int i = 1; i < headerLines.length; i++) { + int idx = headerLines[i].indexOf(':'); + if (idx > 0) { + headers.put(headerLines[i].substring(0, idx), headerLines[i].substring(idx + 1)); + } + } + + if ("CONNECTED".equals(command)) { + synchronized (lock) { + connecting = false; + connected = true; + applySubscriptionsLocked(); + } + publishStatus("Chat connected"); + return; + } + + if ("MESSAGE".equals(command)) { + String destination; + synchronized (lock) { + destination = destinationBySubscription.get(headers.get("subscription")); + } + try { + if (destination != null && destination.startsWith("/topic/chat/conversations/")) { + MessageResponse message = ApiClient.getInstance().getObjectMapper().readValue(bodyPart, MessageResponse.class); + if (messageListener != null) { + messageListener.accept(message); + } + } else { + ConversationResponse conversation = ApiClient.getInstance().getObjectMapper().readValue(bodyPart, ConversationResponse.class); + if (conversationListener != null) { + conversationListener.accept(conversation); + } + } + } catch (Exception e) { + publishStatus("Chat update failed"); + } + return; + } + + if ("ERROR".equals(command)) { + publishStatus("Chat error"); + } + } + + private void publishStatus(String status) { + if (statusListener != null) { + statusListener.accept(status); + } + } + + @Override + public void onOpen(WebSocket webSocket) { + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + synchronized (lock) { + frameBuffer.append(data); + int delimiter; + while ((delimiter = frameBuffer.indexOf("\0")) >= 0) { + String frame = frameBuffer.substring(0, delimiter); + frameBuffer.delete(0, delimiter + 1); + handleFrame(frame); + } + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + synchronized (lock) { + resetConnectionState(); + } + publishStatus("Chat disconnected"); + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + synchronized (lock) { + resetConnectionState(); + } + publishStatus("Chat unavailable"); + } +} diff --git a/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationRequest.java b/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationRequest.java new file mode 100644 index 00000000..cd5ef6ee --- /dev/null +++ b/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationRequest.java @@ -0,0 +1,20 @@ +package org.example.petshopdesktop.api.dto.chat; + +public class ConversationRequest { + private String message; + + public ConversationRequest() { + } + + public ConversationRequest(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java b/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java new file mode 100644 index 00000000..bac384d4 --- /dev/null +++ b/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java @@ -0,0 +1,72 @@ +package org.example.petshopdesktop.api.dto.chat; + +import java.time.LocalDateTime; + +public class ConversationResponse { + private Long id; + private Long customerId; + private Long staffId; + private String status; + private String lastMessage; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public ConversationResponse() { + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public Long getStaffId() { + return staffId; + } + + public void setStaffId(Long staffId) { + this.staffId = staffId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getLastMessage() { + return lastMessage; + } + + public void setLastMessage(String lastMessage) { + this.lastMessage = lastMessage; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageRequest.java b/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageRequest.java new file mode 100644 index 00000000..a5c17ca4 --- /dev/null +++ b/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageRequest.java @@ -0,0 +1,20 @@ +package org.example.petshopdesktop.api.dto.chat; + +public class MessageRequest { + private String content; + + public MessageRequest() { + } + + public MessageRequest(String content) { + this.content = content; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java b/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java new file mode 100644 index 00000000..f81db82d --- /dev/null +++ b/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java @@ -0,0 +1,63 @@ +package org.example.petshopdesktop.api.dto.chat; + +import java.time.LocalDateTime; + +public class MessageResponse { + private Long id; + private Long conversationId; + private Long senderId; + private String content; + private LocalDateTime timestamp; + private Boolean isRead; + + public MessageResponse() { + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getConversationId() { + return conversationId; + } + + public void setConversationId(Long conversationId) { + this.conversationId = conversationId; + } + + public Long getSenderId() { + return senderId; + } + + public void setSenderId(Long senderId) { + this.senderId = senderId; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public Boolean getIsRead() { + return isRead; + } + + public void setIsRead(Boolean isRead) { + this.isRead = isRead; + } +} diff --git a/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java b/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java new file mode 100644 index 00000000..f6429731 --- /dev/null +++ b/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java @@ -0,0 +1,45 @@ +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.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 { + private static final ChatApi INSTANCE = new ChatApi(); + private final ApiClient apiClient; + + private ChatApi() { + this.apiClient = ApiClient.getInstance(); + } + + public static ChatApi getInstance() { + return INSTANCE; + } + + public List listConversations() throws Exception { + String response = apiClient.getRawResponse("/api/v1/chat/conversations"); + return apiClient.getObjectMapper().readValue(response, new TypeReference>() {}); + } + + public ConversationResponse createConversation(ConversationRequest request) throws Exception { + return apiClient.post("/api/v1/chat/conversations", request, ConversationResponse.class); + } + + public ConversationResponse getConversation(Long id) throws Exception { + return apiClient.get("/api/v1/chat/conversations/" + id, ConversationResponse.class); + } + + public List listMessages(Long conversationId) throws Exception { + String response = apiClient.getRawResponse("/api/v1/chat/conversations/" + conversationId + "/messages"); + return apiClient.getObjectMapper().readValue(response, new TypeReference>() {}); + } + + public MessageResponse sendMessage(Long conversationId, MessageRequest request) throws Exception { + return apiClient.post("/api/v1/chat/conversations/" + conversationId + "/messages", request, MessageResponse.class); + } +} diff --git a/src/main/java/org/example/petshopdesktop/auth/Role.class b/src/main/java/org/example/petshopdesktop/auth/Role.class deleted file mode 100644 index b3d3c6cbcacd107579f2ff081e1e439c40d0705f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 995 zcma)4?@!ZE6g{s$)~%Lp%n3441O}`_Y5ZVf+$1c>5|ROl#U;c~-FS{-*DhO2{Ihf< zLNppa`$rk?t5cTD4{MX&-uv!3_q==W&tG4E0C;%)Y|KV{X>TJ#x%%^fF=#3VyJywtJg*3jsXKR3|g^S zYrNgF88+Tc$EPEMtcDo_v&fN}wio!EAzv~Z=gx&=d5+()j#}ru9aePYaZkgXfqC3# zNZ%&x`B9guUpQXGkKQvpFO8vhJ!deej0Nnl@A{p}cm+Wa^A`*}z#>DU>iTZD%`i7k zHA3#2%_LNvE?!6?CG4_+6|9N|#2N++X33ng1yt-WNZAce>Gqk1>+{2?+v5FWr{z)P z<$qL|({-33WxE~U38Ov{$tAN%6Rftq(fwYGHAZxyIvwkFg+aH2sNd#wS13%0#o7`h z*?C3{$_i?ywojBHG<&jE5@nbpSZxGb{zj lvConversations; + + @FXML + private VBox vbMessages; + + @FXML + private ScrollPane spMessages; + + @FXML + private TextArea txtMessage; + + @FXML + private Button btnSend; + + @FXML + private Button btnRefresh; + + @FXML + private Label lblConversationTitle; + + @FXML + private Label lblChatStatus; + + private final ObservableList conversations = FXCollections.observableArrayList(); + private final Map customerLabels = new HashMap<>(); + private final ChatRealtimeClient realtimeClient = ChatRealtimeClient.getInstance(); + private ConversationResponse selectedConversation; + + @FXML + public void initialize() { + lvConversations.setItems(conversations); + lvConversations.setCellFactory(list -> new ListCell<>() { + @Override + protected void updateItem(ConversationResponse item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setGraphic(null); + return; + } + + Label title = new Label(getConversationTitle(item)); + 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); + Label meta = new Label(buildConversationMeta(item)); + meta.setStyle("-fx-text-fill: #94a3b8; -fx-font-size: 11px;"); + VBox box = new VBox(4, title, preview, meta); + setGraphic(box); + } + }); + + lvConversations.getSelectionModel().selectedItemProperty().addListener((obs, oldValue, newValue) -> { + if (newValue != null) { + selectedConversation = newValue; + lblConversationTitle.setText(getConversationTitle(newValue)); + loadMessages(newValue.getId()); + realtimeClient.subscribeToConversation(newValue.getId()); + } + }); + + txtMessage.setOnKeyPressed(event -> { + if (event.getCode() == KeyCode.ENTER && event.isControlDown()) { + btnSendClicked(); + } + }); + + realtimeClient.setConversationListener(conversation -> Platform.runLater(() -> upsertConversation(conversation))); + realtimeClient.setMessageListener(message -> Platform.runLater(() -> appendMessageIfSelected(message))); + realtimeClient.setStatusListener(status -> Platform.runLater(() -> lblChatStatus.setText(status))); + realtimeClient.connect(); + + loadCustomers(); + loadConversations(); + } + + @FXML + void btnRefreshClicked() { + loadConversations(); + if (selectedConversation != null) { + loadMessages(selectedConversation.getId()); + } + } + + @FXML + void btnSendClicked() { + if (selectedConversation == null) { + lblChatStatus.setText("Select a conversation"); + return; + } + + String content = txtMessage.getText() == null ? "" : txtMessage.getText().trim(); + if (content.isEmpty()) { + return; + } + + txtMessage.clear(); + boolean sent = realtimeClient.sendMessage(selectedConversation.getId(), content); + if (!sent) { + sendMessageFallback(selectedConversation.getId(), content); + } + } + + private void loadCustomers() { + new Thread(() -> { + try { + List customers = DropdownApi.getInstance().getCustomers(); + Map labels = new HashMap<>(); + for (DropdownOption option : customers) { + labels.put(option.getId(), option.getLabel()); + } + Platform.runLater(() -> { + customerLabels.clear(); + customerLabels.putAll(labels); + lvConversations.refresh(); + if (selectedConversation != null) { + lblConversationTitle.setText(getConversationTitle(selectedConversation)); + } + }); + } catch (Exception e) { + Platform.runLater(() -> ActivityLogger.getInstance().logException( + "ChatController.loadCustomers", + e, + "Loading customer labels for chat")); + } + }).start(); + } + + private void loadConversations() { + new Thread(() -> { + try { + List response = ChatApi.getInstance().listConversations(); + response.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder()))); + Platform.runLater(() -> { + conversations.setAll(response); + restoreSelection(); + if (selectedConversation == null && !conversations.isEmpty()) { + lvConversations.getSelectionModel().selectFirst(); + } + }); + } catch (Exception e) { + Platform.runLater(() -> { + lblChatStatus.setText("Chat unavailable"); + ActivityLogger.getInstance().logException( + "ChatController.loadConversations", + e, + "Loading conversations"); + }); + } + }).start(); + } + + private void loadMessages(Long conversationId) { + new Thread(() -> { + try { + List messages = ChatApi.getInstance().listMessages(conversationId); + Platform.runLater(() -> renderMessages(messages)); + } catch (Exception e) { + Platform.runLater(() -> ActivityLogger.getInstance().logException( + "ChatController.loadMessages", + e, + "Loading messages for conversation " + conversationId)); + } + }).start(); + } + + private void sendMessageFallback(Long conversationId, String content) { + new Thread(() -> { + try { + MessageResponse response = ChatApi.getInstance().sendMessage(conversationId, new MessageRequest(content)); + Platform.runLater(() -> { + lblChatStatus.setText("Chat fallback active"); + appendMessageIfSelected(response); + }); + } catch (Exception e) { + Platform.runLater(() -> ActivityLogger.getInstance().logException( + "ChatController.sendMessageFallback", + e, + "Sending chat message for conversation " + conversationId)); + } + }).start(); + } + + private void renderMessages(List messages) { + vbMessages.getChildren().clear(); + for (MessageResponse message : messages) { + vbMessages.getChildren().add(createMessageBubble(message)); + } + scrollMessagesToBottom(); + } + + private void appendMessageIfSelected(MessageResponse message) { + upsertConversationForMessage(message); + if (selectedConversation != null && selectedConversation.getId().equals(message.getConversationId())) { + vbMessages.getChildren().add(createMessageBubble(message)); + scrollMessagesToBottom(); + } + } + + private void upsertConversation(ConversationResponse conversation) { + Optional existing = conversations.stream() + .filter(item -> item.getId().equals(conversation.getId())) + .findFirst(); + + if (existing.isPresent()) { + ConversationResponse current = existing.get(); + int index = conversations.indexOf(current); + conversations.set(index, conversation); + } else { + conversations.add(conversation); + } + + conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder()))); + restoreSelection(); + lvConversations.refresh(); + } + + private void upsertConversationForMessage(MessageResponse message) { + conversations.stream() + .filter(conversation -> conversation.getId().equals(message.getConversationId())) + .findFirst() + .ifPresent(conversation -> { + conversation.setLastMessage(message.getContent()); + conversation.setUpdatedAt(message.getTimestamp()); + conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder()))); + lvConversations.refresh(); + }); + } + + private void restoreSelection() { + if (selectedConversation == null) { + return; + } + conversations.stream() + .filter(item -> item.getId().equals(selectedConversation.getId())) + .findFirst() + .ifPresent(match -> { + selectedConversation = match; + lvConversations.getSelectionModel().select(match); + }); + } + + private HBox createMessageBubble(MessageResponse message) { + boolean mine = message.getSenderId() != null && message.getSenderId().equals(UserSession.getInstance().getUserId()); + Label author = new Label(resolveAuthorLabel(message)); + author.setStyle("-fx-font-weight: bold; -fx-text-fill: " + (mine ? "#ffffff" : "#1f2937") + ";"); + + Label content = new Label(message.getContent()); + content.setWrapText(true); + content.setStyle("-fx-text-fill: " + (mine ? "#ffffff" : "#1f2937") + ";"); + + String timestampText = message.getTimestamp() == null ? "" : TIME_FORMATTER.format(message.getTimestamp()); + Label timestamp = new Label(timestampText); + timestamp.setStyle("-fx-text-fill: " + (mine ? "#dbeafe" : "#94a3b8") + "; -fx-font-size: 11px;"); + + VBox bubble = new VBox(4, author, content, timestamp); + bubble.setMaxWidth(420); + bubble.setStyle(mine + ? "-fx-background-color: #0f766e; -fx-background-radius: 14; -fx-padding: 12;" + : "-fx-background-color: #e2e8f0; -fx-background-radius: 14; -fx-padding: 12;"); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + HBox container = new HBox(12); + if (mine) { + container.getChildren().addAll(spacer, bubble); + } else { + container.getChildren().addAll(bubble, spacer); + } + return container; + } + + private String resolveAuthorLabel(MessageResponse message) { + Long currentUserId = UserSession.getInstance().getUserId(); + if (message.getSenderId() != null && message.getSenderId().equals(currentUserId)) { + return "You"; + } + if (selectedConversation != null && selectedConversation.getStaffId() != null && selectedConversation.getStaffId().equals(message.getSenderId())) { + return "Staff"; + } + return "Customer"; + } + + private String getConversationTitle(ConversationResponse conversation) { + String customerLabel = customerLabels.get(conversation.getCustomerId()); + return customerLabel != null ? customerLabel : "Customer #" + conversation.getCustomerId(); + } + + private String buildConversationMeta(ConversationResponse conversation) { + String assignee = conversation.getStaffId() == null ? "Unassigned" : "Assigned"; + String updated = conversation.getUpdatedAt() == null ? "" : TIME_FORMATTER.format(conversation.getUpdatedAt()); + return assignee + (updated.isBlank() ? "" : " ยท " + updated); + } + + private static java.time.LocalDateTime conversationSortTime(ConversationResponse conversation) { + return conversation.getUpdatedAt() != null ? conversation.getUpdatedAt() : conversation.getCreatedAt(); + } + + private void scrollMessagesToBottom() { + Platform.runLater(() -> spMessages.setVvalue(1.0)); + } +} diff --git a/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java b/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java index b71d4991..1b13f49e 100644 --- a/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java +++ b/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java @@ -13,6 +13,7 @@ import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; import javafx.scene.layout.StackPane; import javafx.stage.Stage; +import org.example.petshopdesktop.api.ChatRealtimeClient; import org.example.petshopdesktop.auth.UserSession; import org.example.petshopdesktop.util.ActivityLogger; @@ -185,6 +186,7 @@ public class MainLayoutController { @FXML void btnLogoutClicked(ActionEvent event) { + ChatRealtimeClient.getInstance().disconnect(); UserSession.getInstance().logout(); try { FXMLLoader loader = new FXMLLoader( diff --git a/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml b/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml index 8dc85932..f701a9b5 100644 --- a/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml +++ b/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml @@ -1,18 +1,78 @@ + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +