Add desktop chat
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ connectionpetstore.properties
|
||||
# Log files
|
||||
*.log
|
||||
log.txt
|
||||
*.class
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,336 @@
|
||||
package org.example.petshopdesktop.api;
|
||||
|
||||
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 org.example.petshopdesktop.auth.UserSession;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.WebSocket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class ChatRealtimeClient implements WebSocket.Listener {
|
||||
private static final ChatRealtimeClient INSTANCE = new ChatRealtimeClient();
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final StringBuilder frameBuffer = new StringBuilder();
|
||||
private final AtomicInteger subscriptionCounter = new AtomicInteger(1);
|
||||
private final Map<String, String> 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<ConversationResponse> conversationListener;
|
||||
private Consumer<MessageResponse> messageListener;
|
||||
private Consumer<String> statusListener;
|
||||
|
||||
private ChatRealtimeClient() {
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static ChatRealtimeClient getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public void setConversationListener(Consumer<ConversationResponse> conversationListener) {
|
||||
this.conversationListener = conversationListener;
|
||||
}
|
||||
|
||||
public void setMessageListener(Consumer<MessageResponse> messageListener) {
|
||||
this.messageListener = messageListener;
|
||||
}
|
||||
|
||||
public void setStatusListener(Consumer<String> 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<String, String> 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ConversationResponse> listConversations() throws Exception {
|
||||
String response = apiClient.getRawResponse("/api/v1/chat/conversations");
|
||||
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<ConversationResponse>>() {});
|
||||
}
|
||||
|
||||
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<MessageResponse> listMessages(Long conversationId) throws Exception {
|
||||
String response = apiClient.getRawResponse("/api/v1/chat/conversations/" + conversationId + "/messages");
|
||||
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<MessageResponse>>() {});
|
||||
}
|
||||
|
||||
public MessageResponse sendMessage(Long conversationId, MessageRequest request) throws Exception {
|
||||
return apiClient.post("/api/v1/chat/conversations/" + conversationId + "/messages", request, MessageResponse.class);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,341 @@
|
||||
package org.example.petshopdesktop.controllers;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.example.petshopdesktop.api.ChatRealtimeClient;
|
||||
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 org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.endpoints.ChatApi;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public class ChatController {
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MMM d, HH:mm");
|
||||
|
||||
@FXML
|
||||
private ListView<ConversationResponse> 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<ConversationResponse> conversations = FXCollections.observableArrayList();
|
||||
private final Map<Long, String> 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<DropdownOption> customers = DropdownApi.getInstance().getCustomers();
|
||||
Map<Long, String> 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<ConversationResponse> 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<MessageResponse> 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<MessageResponse> 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<ConversationResponse> 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));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -1,18 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ListView?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.control.Separator?>
|
||||
<?import javafx.scene.control.TextArea?>
|
||||
<?import javafx.scene.layout.BorderPane?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.Priority?>
|
||||
<?import javafx.scene.layout.Region?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import javafx.scene.text.Font?>
|
||||
|
||||
<VBox spacing="20.0" style="-fx-background-color: #f8fafc;" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1">
|
||||
<padding>
|
||||
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
|
||||
</padding>
|
||||
|
||||
<Label text="Customer Chat" textFill="#2c3e50">
|
||||
<font>
|
||||
<Font name="System Bold" size="30.0" />
|
||||
</font>
|
||||
</Label>
|
||||
</VBox>
|
||||
<BorderPane prefHeight="680.0" prefWidth="900.0" style="-fx-background-color: linear-gradient(to bottom right, #f8fafc, #e2e8f0);" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.ChatController">
|
||||
<left>
|
||||
<VBox prefWidth="290.0" spacing="12.0" style="-fx-background-color: #ffffff; -fx-border-color: #dbe4ee; -fx-border-width: 0 1 0 0;">
|
||||
<padding>
|
||||
<Insets bottom="18.0" left="18.0" right="18.0" top="18.0" />
|
||||
</padding>
|
||||
<children>
|
||||
<HBox alignment="CENTER_LEFT" spacing="10.0">
|
||||
<children>
|
||||
<Label text="Chat Inbox" textFill="#1f2937">
|
||||
<font>
|
||||
<Font name="System Bold" size="24.0" />
|
||||
</font>
|
||||
</Label>
|
||||
<Region HBox.hgrow="ALWAYS" />
|
||||
<Button fx:id="btnRefresh" mnemonicParsing="false" onAction="#btnRefreshClicked" style="-fx-background-color: #0f766e; -fx-background-radius: 10; -fx-text-fill: white; -fx-cursor: hand;" text="Refresh">
|
||||
<padding>
|
||||
<Insets bottom="8.0" left="14.0" right="14.0" top="8.0" />
|
||||
</padding>
|
||||
</Button>
|
||||
</children>
|
||||
</HBox>
|
||||
<Label fx:id="lblChatStatus" text="Connecting chat..." textFill="#64748b" />
|
||||
<Separator />
|
||||
<ListView fx:id="lvConversations" prefHeight="620.0" style="-fx-background-color: transparent; -fx-border-color: transparent;" VBox.vgrow="ALWAYS" />
|
||||
</children>
|
||||
</VBox>
|
||||
</left>
|
||||
<center>
|
||||
<VBox spacing="14.0">
|
||||
<padding>
|
||||
<Insets bottom="18.0" left="18.0" right="18.0" top="18.0" />
|
||||
</padding>
|
||||
<children>
|
||||
<Label fx:id="lblConversationTitle" text="Select a conversation" textFill="#0f172a">
|
||||
<font>
|
||||
<Font name="System Bold" size="24.0" />
|
||||
</font>
|
||||
</Label>
|
||||
<ScrollPane fx:id="spMessages" fitToWidth="true" hbarPolicy="NEVER" style="-fx-background-color: transparent; -fx-background: transparent;" VBox.vgrow="ALWAYS">
|
||||
<content>
|
||||
<VBox fx:id="vbMessages" spacing="10.0" style="-fx-background-color: rgba(255,255,255,0.72); -fx-background-radius: 18; -fx-padding: 18;" />
|
||||
</content>
|
||||
</ScrollPane>
|
||||
<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">
|
||||
<children>
|
||||
<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" />
|
||||
</padding>
|
||||
</Button>
|
||||
</children>
|
||||
</HBox>
|
||||
</children>
|
||||
</VBox>
|
||||
</children>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
||||
|
||||
Reference in New Issue
Block a user