Add desktop chat
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ connectionpetstore.properties
|
|||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
log.txt
|
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.inventory to com.fasterxml.jackson.databind;
|
||||||
opens org.example.petshopdesktop.api.dto.appointment 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.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.sale to com.fasterxml.jackson.databind;
|
||||||
opens org.example.petshopdesktop.api.dto.user 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;
|
opens org.example.petshopdesktop.api.dto.analytics to com.fasterxml.jackson.databind;
|
||||||
|
|||||||
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.input.MouseEvent;
|
||||||
import javafx.scene.layout.StackPane;
|
import javafx.scene.layout.StackPane;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
|
import org.example.petshopdesktop.api.ChatRealtimeClient;
|
||||||
import org.example.petshopdesktop.auth.UserSession;
|
import org.example.petshopdesktop.auth.UserSession;
|
||||||
import org.example.petshopdesktop.util.ActivityLogger;
|
import org.example.petshopdesktop.util.ActivityLogger;
|
||||||
|
|
||||||
@@ -185,6 +186,7 @@ public class MainLayoutController {
|
|||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
void btnLogoutClicked(ActionEvent event) {
|
void btnLogoutClicked(ActionEvent event) {
|
||||||
|
ChatRealtimeClient.getInstance().disconnect();
|
||||||
UserSession.getInstance().logout();
|
UserSession.getInstance().logout();
|
||||||
try {
|
try {
|
||||||
FXMLLoader loader = new FXMLLoader(
|
FXMLLoader loader = new FXMLLoader(
|
||||||
|
|||||||
@@ -1,18 +1,78 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
<?import javafx.geometry.Insets?>
|
<?import javafx.geometry.Insets?>
|
||||||
|
<?import javafx.scene.control.Button?>
|
||||||
<?import javafx.scene.control.Label?>
|
<?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.layout.VBox?>
|
||||||
<?import javafx.scene.text.Font?>
|
<?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">
|
<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">
|
||||||
<padding>
|
<left>
|
||||||
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
|
<VBox prefWidth="290.0" spacing="12.0" style="-fx-background-color: #ffffff; -fx-border-color: #dbe4ee; -fx-border-width: 0 1 0 0;">
|
||||||
</padding>
|
<padding>
|
||||||
|
<Insets bottom="18.0" left="18.0" right="18.0" top="18.0" />
|
||||||
<Label text="Customer Chat" textFill="#2c3e50">
|
</padding>
|
||||||
<font>
|
<children>
|
||||||
<Font name="System Bold" size="30.0" />
|
<HBox alignment="CENTER_LEFT" spacing="10.0">
|
||||||
</font>
|
<children>
|
||||||
</Label>
|
<Label text="Chat Inbox" textFill="#1f2937">
|
||||||
</VBox>
|
<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