added closed chat section and fixed closed chat bug for desktop

This commit is contained in:
Alex
2026-04-12 19:49:12 -06:00
parent e4e04940a9
commit f497251873
3 changed files with 189 additions and 57 deletions

View File

@@ -37,12 +37,23 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
public class ChatController { public class ChatController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MMM d, HH:mm"); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MMM d, HH:mm");
private static final int CHAT_CELL_HEIGHT = 80;
@FXML @FXML
private ListView<ConversationResponse> lvConversations; private ListView<ConversationResponse> lvActiveConversations;
@FXML
private ListView<ConversationResponse> lvClosedConversations;
@FXML
private Button btnActiveHeader;
@FXML
private Button btnClosedHeader;
@FXML @FXML
private VBox vbMessages; private VBox vbMessages;
@@ -72,61 +83,49 @@ public class ChatController {
private Label lblChatStatus; private Label lblChatStatus;
private final ObservableList<ConversationResponse> conversations = FXCollections.observableArrayList(); private final ObservableList<ConversationResponse> conversations = FXCollections.observableArrayList();
private final ObservableList<ConversationResponse> activeConversations = FXCollections.observableArrayList();
private final ObservableList<ConversationResponse> closedConversations = FXCollections.observableArrayList();
private final Map<Long, String> customerLabels = new HashMap<>(); private final Map<Long, String> customerLabels = new HashMap<>();
private final ChatRealtimeClient realtimeClient = ChatRealtimeClient.getInstance(); private final ChatRealtimeClient realtimeClient = ChatRealtimeClient.getInstance();
private ConversationResponse selectedConversation; private ConversationResponse selectedConversation;
private File selectedAttachmentFile; private File selectedAttachmentFile;
private boolean activeExpanded = true;
private boolean closedExpanded = true;
private boolean selectionChanging = false;
@FXML @FXML
public void initialize() { public void initialize() {
lvConversations.setItems(conversations); setupConversationListView(lvActiveConversations);
lvConversations.setCellFactory(list -> new ListCell<>() { setupConversationListView(lvClosedConversations);
@Override lvActiveConversations.setItems(activeConversations);
protected void updateItem(ConversationResponse item, boolean empty) { lvClosedConversations.setItems(closedConversations);
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
return;
}
Label title = new Label(getConversationTitle(item)); lvActiveConversations.getSelectionModel().selectedItemProperty().addListener((obs, oldVal, newVal) -> {
if (newVal != null && !selectionChanging) {
// Bold title if needs attention selectionChanging = true;
UserSession session = UserSession.getInstance(); lvClosedConversations.getSelectionModel().clearSelection();
Long currentUserId = session.getUserId(); selectionChanging = false;
boolean needsPickup = item.getHumanRequestedAt() != null && item.getStaffId() == null; onConversationSelected(newVal);
boolean needsReply = currentUserId != null && currentUserId.equals(item.getStaffId())
&& item.getLastSenderId() != null && !item.getLastSenderId().equals(currentUserId);
if (needsPickup || needsReply) {
title.setStyle("-fx-font-weight: bold; -fx-text-fill: #FF6B6B;");
} else {
title.setStyle("-fx-font-weight: bold; -fx-text-fill: #1f2937;");
}
Label preview = new Label(item.getLastMessage() == null ? "" : item.getLastMessage());
preview.setStyle("-fx-text-fill: #64748b;");
preview.setWrapText(true);
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) -> { lvClosedConversations.getSelectionModel().selectedItemProperty().addListener((obs, oldVal, newVal) -> {
if (newValue != null) { if (newVal != null && !selectionChanging) {
selectedConversation = newValue; selectionChanging = true;
lblConversationTitle.setText(getConversationTitle(newValue)); lvActiveConversations.getSelectionModel().clearSelection();
loadMessages(newValue.getId()); selectionChanging = false;
realtimeClient.subscribeToConversation(newValue.getId()); onConversationSelected(newVal);
updateChatState(newValue);
} }
}); });
txtMessage.setOnKeyPressed(event -> { txtMessage.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER && event.isControlDown()) { if (event.getCode() == KeyCode.ENTER) {
btnSendClicked(); if (event.isShiftDown()) {
txtMessage.appendText("\n");
} else {
btnSendClicked();
}
event.consume();
} }
}); });
@@ -145,6 +144,22 @@ public class ChatController {
updateChatState(null); updateChatState(null);
} }
@FXML
void toggleActiveSection() {
activeExpanded = !activeExpanded;
lvActiveConversations.setVisible(activeExpanded);
lvActiveConversations.setManaged(activeExpanded);
updateSectionHeaders();
}
@FXML
void toggleClosedSection() {
closedExpanded = !closedExpanded;
lvClosedConversations.setVisible(closedExpanded);
lvClosedConversations.setManaged(closedExpanded);
updateSectionHeaders();
}
@FXML @FXML
void btnRefreshClicked() { void btnRefreshClicked() {
loadConversations(); loadConversations();
@@ -169,12 +184,12 @@ public class ChatController {
} }
Long convId = selectedConversation.getId(); Long convId = selectedConversation.getId();
txtMessage.clear(); txtMessage.clear();
btnSend.setDisable(true); btnSend.setDisable(true);
lblChatStatus.setText("Sending message..."); lblChatStatus.setText("Sending message...");
new Thread(() -> { new Thread(() -> {
try { try {
MessageRequest request = new MessageRequest(content); MessageRequest request = new MessageRequest(content);
@@ -241,6 +256,17 @@ public class ChatController {
}).start(); }).start();
} }
private void onConversationSelected(ConversationResponse newValue) {
boolean sameConversation = selectedConversation != null && selectedConversation.getId().equals(newValue.getId());
selectedConversation = newValue;
lblConversationTitle.setText(getConversationTitle(newValue));
if (!sameConversation) {
loadMessages(newValue.getId());
realtimeClient.subscribeToConversation(newValue.getId());
}
updateChatState(newValue);
}
private void updateChatState(ConversationResponse conv) { private void updateChatState(ConversationResponse conv) {
boolean closed = conv == null || "CLOSED".equals(conv.getStatus()); boolean closed = conv == null || "CLOSED".equals(conv.getStatus());
txtMessage.setDisable(closed); txtMessage.setDisable(closed);
@@ -248,6 +274,9 @@ public class ChatController {
btnAttachment.setDisable(closed); btnAttachment.setDisable(closed);
btnClose.setVisible(!closed); btnClose.setVisible(!closed);
btnClose.setManaged(!closed); btnClose.setManaged(!closed);
if (!closed) {
btnClose.setDisable(false);
}
} }
private void clearLocalAttachment() { private void clearLocalAttachment() {
@@ -256,6 +285,69 @@ public class ChatController {
btnAttachment.setStyle("-fx-background-color: #e2e8f0; -fx-background-radius: 12; -fx-text-fill: #475569; -fx-cursor: hand;"); btnAttachment.setStyle("-fx-background-color: #e2e8f0; -fx-background-radius: 12; -fx-text-fill: #475569; -fx-cursor: hand;");
} }
private void setupConversationListView(ListView<ConversationResponse> lv) {
lv.setFixedCellSize(CHAT_CELL_HEIGHT);
lv.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));
UserSession session = UserSession.getInstance();
Long currentUserId = session.getUserId();
boolean needsPickup = item.getHumanRequestedAt() != null && item.getStaffId() == null;
boolean needsReply = currentUserId != null && currentUserId.equals(item.getStaffId())
&& item.getLastSenderId() != null && !item.getLastSenderId().equals(currentUserId);
if (needsPickup || needsReply) {
title.setStyle("-fx-font-weight: bold; -fx-text-fill: #FF6B6B;");
} else {
title.setStyle("-fx-font-weight: bold; -fx-text-fill: #1f2937;");
}
Label preview = new Label(item.getLastMessage() == null ? "" : item.getLastMessage());
preview.setStyle("-fx-text-fill: #64748b;");
preview.setTextOverrun(javafx.scene.control.OverrunStyle.ELLIPSIS);
preview.setMaxWidth(Double.MAX_VALUE);
Label meta = new Label(buildConversationMeta(item));
meta.setStyle("-fx-text-fill: #94a3b8; -fx-font-size: 11px;");
VBox box = new VBox(4, title, preview, meta);
box.setMaxWidth(Double.MAX_VALUE);
setGraphic(box);
}
});
}
private void refreshSections() {
activeConversations.setAll(conversations.stream()
.filter(c -> !"CLOSED".equals(c.getStatus()))
.collect(Collectors.toList()));
closedConversations.setAll(conversations.stream()
.filter(c -> "CLOSED".equals(c.getStatus()))
.collect(Collectors.toList()));
updateSectionHeaders();
updateListViewHeights();
}
private void updateSectionHeaders() {
btnActiveHeader.setText((activeExpanded ? "" : "") + " Active Chats (" + activeConversations.size() + ")");
btnClosedHeader.setText((closedExpanded ? "" : "") + " Closed Chats (" + closedConversations.size() + ")");
}
private void updateListViewHeights() {
if (activeExpanded) {
lvActiveConversations.setPrefHeight(activeConversations.size() * CHAT_CELL_HEIGHT + 2);
}
if (closedExpanded) {
lvClosedConversations.setPrefHeight(closedConversations.size() * CHAT_CELL_HEIGHT + 2);
}
}
private void loadCustomers() { private void loadCustomers() {
new Thread(() -> { new Thread(() -> {
try { try {
@@ -267,7 +359,8 @@ public class ChatController {
Platform.runLater(() -> { Platform.runLater(() -> {
customerLabels.clear(); customerLabels.clear();
customerLabels.putAll(labels); customerLabels.putAll(labels);
lvConversations.refresh(); lvActiveConversations.refresh();
lvClosedConversations.refresh();
if (selectedConversation != null) { if (selectedConversation != null) {
lblConversationTitle.setText(getConversationTitle(selectedConversation)); lblConversationTitle.setText(getConversationTitle(selectedConversation));
} }
@@ -288,9 +381,12 @@ public class ChatController {
response.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder()))); response.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
Platform.runLater(() -> { Platform.runLater(() -> {
conversations.setAll(response); conversations.setAll(response);
refreshSections();
restoreSelection(); restoreSelection();
if (selectedConversation == null && !conversations.isEmpty()) { if (selectedConversation == null && !activeConversations.isEmpty()) {
lvConversations.getSelectionModel().selectFirst(); lvActiveConversations.getSelectionModel().selectFirst();
} else if (selectedConversation == null && !closedConversations.isEmpty()) {
lvClosedConversations.getSelectionModel().selectFirst();
} }
}); });
} catch (Exception e) { } catch (Exception e) {
@@ -355,16 +451,15 @@ public class ChatController {
.findFirst(); .findFirst();
if (existing.isPresent()) { if (existing.isPresent()) {
ConversationResponse current = existing.get(); int index = conversations.indexOf(existing.get());
int index = conversations.indexOf(current);
conversations.set(index, conversation); conversations.set(index, conversation);
} else { } else {
conversations.add(conversation); conversations.add(conversation);
} }
conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder()))); conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
refreshSections();
restoreSelection(); restoreSelection();
lvConversations.refresh();
if (selectedConversation != null && selectedConversation.getId().equals(conversation.getId())) { if (selectedConversation != null && selectedConversation.getId().equals(conversation.getId())) {
updateChatState(conversation); updateChatState(conversation);
} }
@@ -378,7 +473,7 @@ public class ChatController {
conversation.setLastMessage(message.getContent()); conversation.setLastMessage(message.getContent());
conversation.setUpdatedAt(message.getTimestamp()); conversation.setUpdatedAt(message.getTimestamp());
conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder()))); conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
lvConversations.refresh(); refreshSections();
}); });
} }
@@ -386,18 +481,26 @@ public class ChatController {
if (selectedConversation == null) { if (selectedConversation == null) {
return; return;
} }
conversations.stream() Long selId = selectedConversation.getId();
.filter(item -> item.getId().equals(selectedConversation.getId())) activeConversations.stream()
.filter(item -> item.getId().equals(selId))
.findFirst() .findFirst()
.ifPresent(match -> { .ifPresent(match -> {
selectedConversation = match; selectedConversation = match;
lvConversations.getSelectionModel().select(match); lvActiveConversations.getSelectionModel().select(match);
});
closedConversations.stream()
.filter(item -> item.getId().equals(selId))
.findFirst()
.ifPresent(match -> {
selectedConversation = match;
lvClosedConversations.getSelectionModel().select(match);
}); });
} }
private HBox createMessageBubble(MessageResponse message) { private HBox createMessageBubble(MessageResponse message) {
boolean mine = message.getSenderId() != null && message.getSenderId().equals(UserSession.getInstance().getUserId()); boolean mine = message.getSenderId() != null && message.getSenderId().equals(UserSession.getInstance().getUserId());
Circle avatar = new Circle(16); Circle avatar = new Circle(16);
avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1")); avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1"));
if (message.getSenderAvatarUrl() != null && !message.getSenderAvatarUrl().isBlank()) { if (message.getSenderAvatarUrl() != null && !message.getSenderAvatarUrl().isBlank()) {
@@ -437,7 +540,7 @@ public class ChatController {
HBox.setHgrow(spacer, Priority.ALWAYS); HBox.setHgrow(spacer, Priority.ALWAYS);
HBox container = new HBox(12); HBox container = new HBox(12);
container.setAlignment(javafx.geometry.Pos.BOTTOM_LEFT); container.setAlignment(javafx.geometry.Pos.BOTTOM_LEFT);
if (mine) { if (mine) {
container.getChildren().addAll(spacer, bubble, avatar); container.getChildren().addAll(spacer, bubble, avatar);
container.setAlignment(javafx.geometry.Pos.BOTTOM_RIGHT); container.setAlignment(javafx.geometry.Pos.BOTTOM_RIGHT);

View File

@@ -38,7 +38,25 @@
</HBox> </HBox>
<Label fx:id="lblChatStatus" text="Connecting chat..." textFill="#64748b" /> <Label fx:id="lblChatStatus" text="Connecting chat..." textFill="#64748b" />
<Separator /> <Separator />
<ListView fx:id="lvConversations" prefHeight="620.0" styleClass="chat-conversation-list" style="-fx-background-color: transparent; -fx-border-color: transparent;" VBox.vgrow="ALWAYS" /> <ScrollPane VBox.vgrow="ALWAYS" fitToWidth="true" hbarPolicy="NEVER" style="-fx-background: transparent; -fx-background-color: transparent; -fx-border-color: transparent;">
<content>
<VBox spacing="4.0">
<Button fx:id="btnActiveHeader" maxWidth="Infinity" alignment="CENTER_LEFT" mnemonicParsing="false" onAction="#toggleActiveSection" style="-fx-background-color: #f1f5f9; -fx-cursor: hand; -fx-background-radius: 8; -fx-font-weight: bold; -fx-text-fill: #374151;" text="▼ Active Chats (0)">
<padding>
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
</padding>
</Button>
<ListView fx:id="lvActiveConversations" styleClass="chat-conversation-list" style="-fx-background-color: transparent; -fx-border-color: transparent;" />
<Separator />
<Button fx:id="btnClosedHeader" maxWidth="Infinity" alignment="CENTER_LEFT" mnemonicParsing="false" onAction="#toggleClosedSection" style="-fx-background-color: #f1f5f9; -fx-cursor: hand; -fx-background-radius: 8; -fx-font-weight: bold; -fx-text-fill: #374151;" text="▼ Closed Chats (0)">
<padding>
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
</padding>
</Button>
<ListView fx:id="lvClosedConversations" styleClass="chat-conversation-list" style="-fx-background-color: transparent; -fx-border-color: transparent;" />
</VBox>
</content>
</ScrollPane>
</children> </children>
</VBox> </VBox>
</left> </left>

View File

@@ -100,6 +100,7 @@
-fx-background-color: transparent; -fx-background-color: transparent;
-fx-background-radius: 14; -fx-background-radius: 14;
-fx-padding: 10 10 10 10; -fx-padding: 10 10 10 10;
-fx-pref-width: 0;
} }
.chat-conversation-list .list-cell:filled:selected, .chat-conversation-list .list-cell:filled:selected,
@@ -113,6 +114,16 @@
-fx-background-insets: 0; -fx-background-insets: 0;
} }
.chat-conversation-list .scroll-bar {
-fx-pref-width: 0;
-fx-pref-height: 0;
visibility: hidden;
}
.chat-conversation-list .corner {
visibility: hidden;
}
.chat-messages-scroll-pane, .chat-messages-scroll-pane,
.chat-messages-scroll-pane > .viewport { .chat-messages-scroll-pane > .viewport {
-fx-background-color: transparent; -fx-background-color: transparent;