Add desktop chat

This commit is contained in:
2026-03-10 20:04:32 -06:00
parent bdde1d39dc
commit e17cde6b87
13 changed files with 973 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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