Files
Telegram-Final-Project/src/main/java/org/to/telegramfinalproject/UI/ChatPageController.java
T
2025-08-31 19:29:14 +03:30

1116 lines
41 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package org.to.telegramfinalproject.UI;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.geometry.Side;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Circle;
import javafx.stage.FileChooser;
import org.json.JSONArray;
import org.json.JSONObject;
import org.to.telegramfinalproject.Client.ActionHandler;
import org.to.telegramfinalproject.Client.AvatarLocalResolver;
import org.to.telegramfinalproject.Client.Session;
import org.to.telegramfinalproject.Models.ChatEntry;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.File;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.UUID;
public class ChatPageController {
// ===== messages area =====
@FXML
private VBox messageContainer;
@FXML
private ScrollPane messageScrollPane;
// ===== input area =====
@FXML
private TextArea messageInput;
@FXML
private Button sendButton;
@FXML
private Button attachmentButton;
@FXML
private ImageView attachmentIcon; // <ImageView> inside the attachment button
// ===== header =====
@FXML
private ImageView userAvatar; // 36x36 in the FXML
@FXML
private Label chatTitle; // contact/group title
@FXML
private Label chatStatus; // last seen / online
@FXML
private Button searchInChatButton; // magnifier button
@FXML
private ImageView searchIcon;
@FXML
private Button moreButton; // 3-dots button
@FXML
private ImageView moreIcon;
@FXML
private ContextMenu moreMenu;
@FXML
private MenuItem viewProfileItem;
@FXML
private MenuItem deleteChatItem;
// ===== send icon =====
@FXML
private ImageView sendIcon;
// ===== Time formatter for messages =====
private static final DateTimeFormatter FMT_HHMM = DateTimeFormatter.ofPattern("HH:mm");
private static final DateTimeFormatter FMT_DATE_TIME = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm");
private static final String YESTERDAY_LABEL = "Yesterday";
// ===== state =====
private String chatName;
private final ThemeManager themeManager = ThemeManager.getInstance();
private final java.util.Set<String> pendingClientMsgIds =
java.util.Collections.newSetFromMap(new java.util.concurrent.ConcurrentHashMap<>());
// Where your icons live
private static final String ICON_BASE = "/org/to/telegramfinalproject/Icons/";
private ChatEntry currentChat;
private UUID me;
// ----- helpers for safe strings -----
private static String nz(String s) {
return s == null ? "" : s.trim();
}
private static boolean hasVal(String s) {
if (s == null) return false;
String t = s.trim();
return !t.isEmpty() && !"null".equalsIgnoreCase(t);
}
private void initCurrentUserId() {
try {
String meStr = org.to.telegramfinalproject.Client.Session
.currentUser.getString("internal_uuid");
me = UUID.fromString(meStr);
} catch (Exception ignore) {
me = null;
}
}
private static String str(org.json.JSONObject j, String k) {
try { return (j.has(k) && !j.isNull(k)) ? j.getString(k) : ""; } catch (Exception e) { return ""; }
}
private static boolean bool(org.json.JSONObject j, String k) {
try { return (j.has(k) && !j.isNull(k)) && j.getBoolean(k); } catch (Exception e) { return false; }
}
private static org.json.JSONArray arr(org.json.JSONObject j, String k) {
try { return (j.has(k) && !j.isNull(k)) ? j.getJSONArray(k) : null; } catch (Exception e) { return null; }
}
private String formatWhen(LocalDateTime ts) {
if (ts == null) return "";
LocalDate today = LocalDate.now();
LocalDate d = ts.toLocalDate();
if (d.isEqual(today)) {
return FMT_HHMM.format(ts);
} else if (d.isEqual(today.minusDays(1))) {
return YESTERDAY_LABEL + " " + FMT_HHMM.format(ts);
} else {
return FMT_DATE_TIME.format(ts);
}
}
/** ISO → LocalDateTime (با پشتیبانی از Offset/Z) */
private LocalDateTime parseWhen(String iso) {
if (iso == null || iso.isEmpty()) return null;
try {
return OffsetDateTime.parse(iso).toLocalDateTime();
} catch (Exception ignore) {
try {
return LocalDateTime.parse(iso);
} catch (Exception e) {
return null;
}
}
}
@FXML
public void initialize() {
initCurrentUserId();
// Send button
if (sendButton != null) {
sendButton.setOnAction(e -> sendMessage());
}
// ENTER = send, SHIFT+ENTER = newline
messageInput.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == javafx.scene.input.KeyCode.ENTER) {
if (e.isShiftDown()) {
// let newline happen
} else {
e.consume(); // block newline
sendMessage();
}
}
});
// Enable/disable send button by content (ignore spaces/newlines)
messageInput.textProperty().addListener((obs, oldV, newV) -> {
boolean hasRealText = newV != null && !newV.trim().isEmpty();
sendButton.setDisable(!hasRealText);
// toggle style class for color state
var sc = sendButton.getStyleClass();
sc.removeAll("send-empty", "send-ready");
sc.add(hasRealText ? "send-ready" : "send-empty");
});
// Auto-resize input box like Telegram
messageInput.textProperty().addListener((obs, oldText, newText) -> {
Platform.runLater(() -> {
var textNode = messageInput.lookup(".text");
if (textNode != null) {
double textHeight = textNode.getBoundsInLocal().getHeight();
double padding = 20; // top + bottom padding
double newHeight = textHeight + padding;
if (newHeight < 40) newHeight = 40; // min (1 row)
if (newHeight > 120) newHeight = 120; // max (~5 rows)
messageInput.setPrefHeight(newHeight);
}
});
});
// Start in "empty" state
sendButton.getStyleClass().add("send-empty");
// attach file
if (attachmentButton != null) {
attachmentButton.setOnAction(e -> openFileChooser());
}
// Hook "More" button → show menu under it
if (moreButton != null) {
moreButton.setOnAction(e -> {
if (!moreMenu.isShowing()) {
moreMenu.show(moreButton, Side.BOTTOM, 0, 0);
} else {
moreMenu.hide();
}
});
}
// Menu item actions
viewProfileItem.setOnAction(e -> {
System.out.println("Viewing profile of " + chatName);
// TODO: open profile UI
});
deleteChatItem.setOnAction(e -> {
System.out.println("Deleting chat with " + chatName);
// TODO: delete logic
});
// Initial icon sync once the Scene is ready (stylesheet applied)
Platform.runLater(this::syncIconsWithTheme);
// Auto-Scroll
messageContainer.heightProperty().addListener((obs, oldVal, newVal) -> {
Platform.runLater(() -> {
var timeline = new javafx.animation.Timeline();
var kv = new javafx.animation.KeyValue(
messageScrollPane.vvalueProperty(), 1.0, javafx.animation.Interpolator.EASE_BOTH
);
var kf = new javafx.animation.KeyFrame(javafx.util.Duration.millis(200), kv);
timeline.getKeyFrames().add(kf);
timeline.play();
});
});
// Smooth scroll feel
messageScrollPane.getStylesheets().add(getClass().getResource("/org/to/telegramfinalproject/CSS/scrollpane.css").toExternalForm());
messageScrollPane.setPannable(true);
messageScrollPane.setFitToWidth(true);
messageScrollPane.setFitToHeight(false);
messageScrollPane.getContent().setOnScroll(event -> {
double deltaY = event.getDeltaY() * 0.00003; // smaller = smoother
messageScrollPane.setVvalue(messageScrollPane.getVvalue() - deltaY);
});
// React to theme changes everywhere
themeManager.darkModeProperty().addListener((o, oldVal, isDark) -> syncIconsWithTheme());
}
// private void initCurrentUserId() {
// try {
// String meStr = org.to.telegramfinalproject.Client.Session
// .currentUser.getString("internal_uuid");
// me = UUID.fromString(meStr);
// } catch (Exception ignore) {
// me = null;
// }
// }
//
// private String formatWhen(LocalDateTime ts) {
// if (ts == null) return "";
// LocalDate today = LocalDate.now();
// LocalDate d = ts.toLocalDate();
//
// if (d.isEqual(today)) {
// return FMT_HHMM.format(ts);
// } else if (d.isEqual(today.minusDays(1))) {
// return YESTERDAY_LABEL + " " + FMT_HHMM.format(ts);
// } else {
// return FMT_DATE_TIME.format(ts);
// }
// }
//
// /** ISO → LocalDateTime (با پشتیبانی از Offset/Z) */
// private LocalDateTime parseWhen(String iso) {
// if (iso == null || iso.isEmpty()) return null;
// try {
// return OffsetDateTime.parse(iso).toLocalDateTime();
// } catch (Exception ignore) {
// try {
// return LocalDateTime.parse(iso);
// } catch (Exception e) {
// return null;
// }
// }
// }
@FXML
private void openSearchPanel() {
MainController.getInstance().showSearchPanel();
}
// /**
// * Called by main controller when opening a chat.
// */
// public void setChat(String chatName, String avatarPath) {
// this.chatName = chatName;
//
// // Header text (if present)
// if (chatTitle != null) chatTitle.setText(chatName);
// if (chatStatus != null) chatStatus.setText("last seen recently"); // or live status
//
// // Load avatar if provided
// if (userAvatar != null && avatarPath != null) {
// try {
// Image avatarImg = new Image(getClass().getResourceAsStream(avatarPath));
// userAvatar.setImage(avatarImg);
// Circle clip = new Circle(18, 18, 18); // x, y, radius
// userAvatar.setClip(clip);
// } catch (Exception e) {
// System.err.println("Could not load avatar: " + avatarPath);
// }
// }
//
// addSystemMessage("Chat with " + chatName + " opened.");
// Platform.runLater(() -> messageInput.requestFocus());
// }
// ----- actions -----
// private void sendMessage() {
// String text = messageInput.getText() == null ? "" : messageInput.getText().trim();
// if (!text.isEmpty()) {
// addMessage("You", text);
// messageInput.clear();
// // TODO: send to server
// }
// }
private void sendMessage() {
// 0) Read and validate input
String raw = messageInput.getText();
String text = (raw == null) ? "" : raw.trim();
if (text.isEmpty()) return;
if (currentChat == null) {
addSystemMessage("No chat is selected.");
return;
}
// 1) Clear input immediately for good UX
messageInput.clear();
// 2) Snapshot chat info (must be final for lambdas)
final UUID targetChatId = currentChat.getId();
final String targetType = currentChat.getType(); // "private" | "group" | "channel"
final String contentToSend = text; // effectively final
// 3) Build the SAME JSON as your console method (for TEXT only)
org.json.JSONObject req = new org.json.JSONObject();
req.put("action", "send_message");
req.put("receiver_type", targetType);
req.put("receiver_id", targetChatId.toString());
req.put("content", contentToSend);
req.put("message_type", "TEXT");
// 4) Send on a background thread
new Thread(() -> {
org.json.JSONObject resp;
try {
resp = org.to.telegramfinalproject.Client.ActionHandler.sendWithResponse(req);
} catch (Exception ex) {
ex.printStackTrace();
Platform.runLater(() -> addSystemMessage("Send failed: " + ex.getMessage()));
return;
}
// 5) Check status like your console method
if (resp == null || !"success".equalsIgnoreCase(resp.optString("status"))) {
String err = (resp != null) ? resp.optString("message", "No response") : "No response";
Platform.runLater(() -> addSystemMessage("Send failed: " + err));
return;
}
// 6) Extract fields (your console reads data.message_id; handle both shapes)
org.json.JSONObject data = resp.optJSONObject("data");
String messageId = null;
String sendAtIso = null;
if (data != null) {
// If server returns { data: { message_id, send_at, ... } }
messageId = data.optString("message_id", null);
// Some servers nest: { data: { message: {...} } }
if (messageId == null) {
org.json.JSONObject msgObj = data.optJSONObject("message");
if (msgObj != null) {
messageId = msgObj.optString("message_id", null);
sendAtIso = msgObj.optString("send_at", null);
}
} else {
sendAtIso = data.optString("send_at", null);
}
}
if (messageId == null) messageId = java.util.UUID.randomUUID().toString();
final java.time.LocalDateTime ts =
(sendAtIso != null && !sendAtIso.isBlank()) ? parseWhen(sendAtIso)
: java.time.LocalDateTime.now();
final String fMessageId = messageId;
final java.time.LocalDateTime fTs = ts;
// 7) Update UI on FX thread (render outgoing bubble + index for reply previews)
Platform.runLater(() -> {
// If user switched chats while sending, dont render here
if (currentChat == null || !currentChat.getId().equals(targetChatId)) return;
addBubble(
true, // outgoing
"You", // display name
"TEXT", // message type
contentToSend, // content
fTs, // timestamp
fMessageId, // message_id
"", "", "", // forwarded_from, forwarded_by, reply_to_id
false, // edited
null // reactions
);
//Real time
var mc = MainController.getInstance();
if (mc != null) {
String preview = "You: " + (contentToSend.isBlank() ? "[Message]" : contentToSend);
mc.onChatUpdated(targetChatId, targetType, fTs, /*isIncoming*/ false, preview);
}
// Keep it in msgIndex for reply previews
org.json.JSONObject idx = new org.json.JSONObject();
idx.put("message_id", fMessageId);
idx.put("message_type", "TEXT");
idx.put("content", contentToSend);
idx.put("sender_name", "You");
idx.put("sender_id", (me != null) ? me.toString() : "");
idx.put("send_at", fTs.toString());
msgIndex.put(fMessageId, idx);
});
}).start();
}
private void openFileChooser() {
FileChooser fc = new FileChooser();
fc.setTitle("Select a file to send");
File file = fc.showOpenDialog(attachmentButton.getScene().getWindow());
if (file != null) {
System.out.println("Selected file: " + file.getAbsolutePath());
// TODO: actually send file
addSystemMessage("Attached file: " + file.getName());
}
}
// ----- UI helpers -----
// /**
// * Add a normal message bubble (very simple for now).
// */
// public void addMessage(String sender, String content) {
// Label msg = new Label(sender + ": " + content);
// msg.setWrapText(true);
//
// boolean dark = themeManager.isDarkMode();
// String bubbleColor = dark ? "#20405a" : "#4fa8f0";
// String textColor = dark ? "#e8f1f8" : "#0f141a";
// msg.setStyle(
// "-fx-background-color: " + bubbleColor + ";" +
// "-fx-text-fill: " + textColor + ";" +
// "-fx-padding: 6 10; -fx-background-radius: 10;"
// );
//
// messageContainer.getChildren().add(msg);
// }
private void addSystemMessage(String content) {
Label sys = new Label(content);
sys.setStyle("-fx-text-fill: gray; -fx-font-size: 11;");
messageContainer.getChildren().add(sys);
messageScrollPane.layout();
messageScrollPane.setVvalue(1.0);
}
/**
* Update all header/footer icons according to current theme.
*/
private void syncIconsWithTheme() {
boolean dark = themeManager.isDarkMode();
// We use “_light” icons on dark backgrounds, and “_dark” on light backgrounds.
String suffix = dark ? "_light.png" : "_dark.png";
// attachment
if (attachmentIcon != null) {
attachmentIcon.setImage(loadIcon("attachment" + suffix));
}
// send
if (sendIcon != null) {
sendIcon.setImage(loadIcon("send_cyan2.png"));
}
// header icons
if (searchIcon != null) {
searchIcon.setImage(loadIcon("search" + suffix));
}
if (moreIcon != null) {
moreIcon.setImage(loadIcon("more" + suffix));
}
// header text tint (if youre not fully relying on CSS)
if (chatTitle != null) chatTitle.setStyle(dark ? "-fx-text-fill:#e8f1f8;" : "-fx-text-fill:#0f141a;");
if (chatStatus != null) chatStatus.setStyle(dark ? "-fx-text-fill:#8ea1b2;" : "-fx-text-fill:#7e8a97;");
// View profile icon in more button
((ImageView) viewProfileItem.getGraphic())
.setImage(loadIcon("view_profile" + suffix));
}
private Image loadIcon(String filename) {
var url = getClass().getResource(ICON_BASE + filename);
if (url == null) {
System.err.println("Icon not found: " + ICON_BASE + filename);
return null;
}
return new Image(url.toExternalForm());
}
public void showChat(ChatEntry entry) {
this.currentChat = entry;
chatTitle.setText(entry.getName());
// آواتار پیش‌فرض بر اساس نوع
// ChatPageController.showChat(...)
if (entry.getImageUrl() != null && !entry.getImageUrl().isEmpty()) {
Image img = AvatarLocalResolver.load(entry.getImageUrl());
if (img != null) {
userAvatar.setImage(img);
} else {
// ⬇️ فال‌بک بر اساس نوع
setDefaultHeaderAvatarByType(entry.getType());
}
} else {
setDefaultHeaderAvatarByType(entry.getType());
}
// userAvatar.setClip(new Circle(20, 20, 20));
AvatarFX.circleClip(userAvatar, 36);
fetchAndRenderHeader(entry);
// پیام‌ها
messageContainer.getChildren().clear();
loadMessages(entry);
markAsRead(entry);
Platform.runLater(() -> messageInput.requestFocus());
}
private void loadMessages(ChatEntry entry) {
JSONObject req = new JSONObject();
req.put("action", "get_messages_UI");
req.put("receiver_id", String.valueOf(entry.getId()));
req.put("receiver_type", entry.getType());
req.put("limit", 50);
new Thread(() -> {
JSONObject resp;
try {
resp = org.to.telegramfinalproject.Client.ActionHandler.sendWithResponse(req);
} catch (Exception ex) {
ex.printStackTrace();
return;
}
if (resp == null) return;
String status = "";
try {
status = resp.getString("status");
} catch (Exception ignore) {
}
if (!"success".equals(status)) return;
JSONObject data = null;
try {
data = resp.getJSONObject("data");
} catch (Exception ignore) {
}
if (data == null) return;
JSONArray arr = null;
try {
arr = data.getJSONArray("messages");
} catch (Exception ignore) {
}
if (arr == null) return;
JSONArray finalArr = arr;
Platform.runLater(() -> renderMessages(finalArr));
}).start();
}
// private void renderMessages(org.json.JSONArray arr) {
// messageContainer.getChildren().clear();
//
// String myId = Session.currentUser != null
// ? Session.currentUser.optString("internal_uuid", "")
// : "";
//
// for (int i = 0; i < arr.length(); i++) {
// org.json.JSONObject m = arr.getJSONObject(i);
//
// String senderId = m.has("sender_id") && !m.isNull("sender_id") ? m.getString("sender_id") : "";
// String senderName = m.has("sender_name") && !m.isNull("sender_name") ? m.getString("sender_name") : "";
// String type = m.has("message_type") && !m.isNull("message_type") ? m.getString("message_type") : "TEXT";
// String content = m.has("content") && !m.isNull("content") ? m.getString("content") : "";
// String whenStr = m.has("send_at") && !m.isNull("send_at") ? m.getString("send_at") : null;
//
// boolean outgoing = senderId.equalsIgnoreCase(myId);
//
// String display = outgoing ? "You"
// : (!senderName.isEmpty() ? senderName
// : (senderId.isEmpty() ? "Unknown"
// : senderId.substring(0, Math.min(8, senderId.length()))));
//
// String text;
// switch (type.toUpperCase()) {
// case "IMAGE": text = "[Image]"; break;
// case "AUDIO": text = "[Audio]"; break;
// case "VIDEO": text = "[Video]"; break;
// case "FILE": text = "[File]"; break;
// default: text = content; break;
// }
//
// LocalDateTime ts = parseWhen(whenStr);
// addBubble(outgoing, display, text, ts);
// }
//
// messageScrollPane.layout();
// messageScrollPane.setVvalue(1.0);
// }
private final java.util.Map<String, org.json.JSONObject> msgIndex = new java.util.HashMap<>();
private void renderMessages(org.json.JSONArray list) {
messageContainer.getChildren().clear();
// برای reply-preview: ایندکس کردن پیام‌ها با message_id
msgIndex.clear();
for (int i = 0; i < list.length(); i++) {
org.json.JSONObject m = list.getJSONObject(i);
String mid = str(m, "message_id");
if (!mid.isEmpty()) msgIndex.put(mid, m);
}
String myId = (Session.currentUser != null && Session.currentUser.has("internal_uuid"))
? Session.currentUser.getString("internal_uuid") : "";
for (int i = 0; i < list.length(); i++) {
org.json.JSONObject m = list.getJSONObject(i);
String senderId = str(m, "sender_id");
String senderName = str(m, "sender_name");
String type = str(m, "message_type");
String content = str(m, "content");
String whenStr = str(m, "send_at");
String msgId = str(m, "message_id");
// فوروارد / ریپلای / ادیت / ری‌اکشن
String fwdFrom = nz(str(m, "forwarded_from"));
String fwdBy = nz(str(m, "forwarded_by"));
String replyToId = nz(str(m, "reply_to_id"));
boolean edited = bool(m, "is_edited");
org.json.JSONArray reactions = arr(m, "reactions");
boolean outgoing = senderId.equalsIgnoreCase(myId);
if (senderName == null || senderName.isBlank()) {
senderName = outgoing ? "You"
: (senderId == null || senderId.isBlank()
? "Unknown"
: senderId.substring(0, Math.min(8, senderId.length())));
}
java.time.LocalDateTime ts = parseWhen(whenStr);
// نمایش
addBubble(outgoing, senderName, type, content, ts, msgId,
fwdFrom, fwdBy, replyToId, edited, reactions);
}
// کمی فاصله بین پیام‌ها
messageContainer.setSpacing(8);
messageScrollPane.layout();
messageScrollPane.setVvalue(1.0);
}
private String shortId(String id){
return (id==null||id.isEmpty()) ? "Unknown" : id.substring(0, Math.min(8,id.length()));
}
private void markAsRead(ChatEntry entry) {
JSONObject readReq = new JSONObject();
readReq.put("action", "mark_as_read");
readReq.put("receiver_id", entry.getId().toString()); // internal_id
readReq.put("receiver_type", entry.getType());
ActionHandler.sendWithResponse(readReq);
}
// private void addBubble(boolean outgoing, String displayName, String content, LocalDateTime sentAt) {
//
// Label meta = new Label(displayName + " • " + formatWhen(sentAt));
// meta.setStyle("-fx-font-size: 11; -fx-text-fill: #7e8a97;");
// meta.setWrapText(true);
//
// Label msg = new Label(content);
// msg.setWrapText(true);
//
// boolean dark = themeManager.isDarkMode();
// String mine = dark ? "#2b7cff" : "#d8ecff";
// String theirs = dark ? "#2c333a" : "#ffffff";
// String bg = outgoing ? mine : theirs;
//
// msg.setStyle(
// "-fx-background-color:" + bg + ";" +
// "-fx-padding:8 12;" +
// "-fx-background-radius:12;" +
// "-fx-max-width: 520;"
// );
// msg.setMinHeight(Region.USE_PREF_SIZE);
//
// VBox bubble = new VBox(4, meta, msg);
//
// javafx.scene.layout.HBox row = new javafx.scene.layout.HBox(bubble);
// row.setFillHeight(true);
// row.setSpacing(6);
// row.setAlignment(outgoing
// ? javafx.geometry.Pos.CENTER_RIGHT
// : javafx.geometry.Pos.CENTER_LEFT);
//
// messageContainer.getChildren().add(row);
// }
private void addBubble(
boolean outgoing,
String displayName,
String type,
String content,
java.time.LocalDateTime sentAt,
String messageId,
String forwardedFrom,
String forwardedBy,
String replyToId,
boolean edited,
org.json.JSONArray reactions
) {
String metaText = (displayName == null ? "" : displayName) + " • " + formatWhen(sentAt);
if (edited) metaText += " (edited)";
Label meta = new Label(metaText);
meta.setStyle("-fx-font-size: 11; -fx-text-fill: #7e8a97;");
meta.setWrapText(true);
String t = type == null ? "" : type.trim().toUpperCase();
boolean isText = t.isEmpty() ? (content != null && !content.isBlank()) : "TEXT".equals(t);
String bodyText = isText ? (content == null ? "" : content) : bracketLabel(t);
Label msg = new Label(bodyText);
msg.setWrapText(true);
boolean dark = themeManager.isDarkMode();
String mine = dark ? "#2b7cff" : "#d8ecff";
String theirs = dark ? "#2c333a" : "#ffffff";
String bg = outgoing ? mine : theirs;
msg.setStyle(
"-fx-background-color:" + bg + ";" +
"-fx-padding:8 12;" +
"-fx-background-radius:12;" +
"-fx-max-width: 520;"
);
msg.setMinHeight(Region.USE_PREF_SIZE);
// بدنه حباب
VBox bubble = new VBox(4); // spacing عمودی داخل حباب
bubble.getChildren().add(meta);
// Forward header (اختیاری)
if (hasVal(forwardedFrom) || hasVal(forwardedBy)) {
bubble.getChildren().add(buildForwardHeader(forwardedFrom, forwardedBy));
}
// Reply preview (اختیاری)
if (hasVal(replyToId)) {
bubble.getChildren().add(buildReplyBoxFromIndex(replyToId));
}
// متن اصلی
bubble.getChildren().add(msg);
// Reactions (اختیاری)
if (reactions != null && reactions.length() > 0) {
bubble.getChildren().add(buildReactionsBarFromJson(reactions, dark));
}
// چیدمان راست/چپ
javafx.scene.layout.HBox row = new javafx.scene.layout.HBox(bubble);
row.setFillHeight(true);
row.setSpacing(4);
row.setAlignment(outgoing ? javafx.geometry.Pos.CENTER_RIGHT
: javafx.geometry.Pos.CENTER_LEFT);
row.setPadding(new javafx.geometry.Insets(2, 6, 2, 6));
messageContainer.getChildren().add(row);
}
private String bracketLabel(String t) {
String tt = (t == null) ? "" : t.trim().toUpperCase();
switch (tt) {
case "IMAGE": return "[Image]";
case "AUDIO": return "[Audio]";
case "VIDEO": return "[Video]";
case "FILE": return "[File]";
default: return "[Message]";
}
}
private javafx.scene.Node buildForwardHeader(String forwardedFrom, String forwardedBy) {
String from = hasVal(forwardedFrom) ? forwardedFrom.trim() : null;
String by = hasVal(forwardedBy) ? forwardedBy.trim() : null;
String txt = (from != null && by != null) ? ("Forwarded from " + from + " by " + by)
: (from != null) ? ("Forwarded from " + from)
: (by != null) ? ("Forwarded by " + by)
: "Forwarded";
Label l = new Label(txt);
l.setStyle("-fx-font-size: 11; -fx-text-fill: #5b8bb1;");
l.setWrapText(true);
return l;
}
private javafx.scene.Node buildReplyBoxFromIndex(String replyToId) {
org.json.JSONObject r = msgIndex.get(replyToId);
String from = "Unknown";
String preview;
if (r != null) {
String rType = r.optString("message_type", "TEXT");
String rContent = r.optString("content", "");
String rSender = r.optString("sender_name", "");
if (hasVal(rSender)) from = rSender;
preview = "TEXT".equalsIgnoreCase(rType) ? rContent : bracketLabel(rType);
} else {
preview = "[Quoted message]";
}
Label t = new Label("Reply to " + from);
t.setStyle("-fx-font-size: 11; -fx-text-fill: #5b8bb1; -fx-font-weight: bold;");
Label p = new Label(ellipsize(preview == null ? "" : preview, 140));
p.setWrapText(true);
p.setStyle("-fx-font-size: 12; -fx-text-fill: #6b7c8a;");
VBox box = new VBox(2, t, p);
box.setStyle(
"-fx-background-color: rgba(125,150,175,0.10);" +
"-fx-padding:6 8; -fx-background-radius:8;" +
"-fx-border-color:#7da0b8; -fx-border-width:0 0 0 2;"
);
return box;
}
private javafx.scene.Node buildReactionsBarFromJson(org.json.JSONArray arr, boolean dark) {
javafx.scene.layout.HBox bar = new javafx.scene.layout.HBox(6);
bar.setAlignment(javafx.geometry.Pos.CENTER_LEFT);
for (int i = 0; i < arr.length(); i++) {
org.json.JSONObject ro = arr.optJSONObject(i);
if (ro == null) continue;
String emoji = ro.optString("emoji", "👍");
int count = ro.optInt("count", 1);
boolean byMe = ro.optBoolean("by_me", false);
String chipBg = byMe ? (dark ? "#215a9f" : "#d1e8ff")
: (dark ? "#2f3942" : "#eef3f7");
String text = emoji + (count > 0 ? (" " + count) : "");
Label chip = new Label(text);
chip.setStyle(
"-fx-font-size: 12;" +
"-fx-background-radius: 12;" +
"-fx-padding: 2 8;" +
"-fx-background-color: " + chipBg + ";"
);
bar.getChildren().add(chip);
}
return bar;
}
private static String ellipsize(String s, int max) { return s.length() > max ? s.substring(0, max) + "…" : s; }
public boolean isSameChat(UUID chatId, String type) {
return currentChat != null
&& currentChat.getId().equals(chatId)
&& currentChat.getType().equalsIgnoreCase(type);
}
public void onRealTimeNewMessage(JSONObject m) {
try {
String chatIdStr = str(m,"receiver_id");
String chatType = str(m,"receiver_type");
if (chatIdStr.isEmpty() || chatType.isEmpty()) return;
UUID chatId = UUID.fromString(chatIdStr);
if (!isSameChat(chatId, chatType)) {
System.out.println("[UI] RT msg for another chat: " + chatId);
return;
}
// id → message_id fallback
if (!m.has("message_id") && m.has("id")) {
m.put("message_id", m.getString("id"));
}
String senderName = hasVal(str(m,"sender_name")) ? str(m,"sender_name")
: (hasVal(str(m,"sender_id")) ? shortId(str(m,"sender_id")) : "Unknown");
String type = hasVal(str(m,"message_type")) ? str(m,"message_type") : "TEXT";
String content = str(m,"content");
String whenIso = str(m,"send_at");
String msgId = str(m,"message_id");
LocalDateTime ts = parseWhen(whenIso);
if (ts == null) ts = LocalDateTime.now();
addBubble(false, senderName, type, content, ts, msgId,
str(m,"forwarded_from"), str(m,"forwarded_by"), str(m,"reply_to_id"),
bool(m,"is_edited"), arr(m,"reactions"));
if (hasVal(msgId)) msgIndex.put(msgId, m);
if (currentChat != null) markAsRead(currentChat);
} catch (Exception e) {
e.printStackTrace();
}
}
private void fetchAndRenderHeader(ChatEntry entry) {
JSONObject req = new JSONObject();
req.put("action", "get_header_info");
req.put("receiver_id", entry.getId().toString());
req.put("receiver_type", entry.getType()); // باید "private" باشه
// 👇 اضافه کن: آی‌دی کاربر فعلی (current user)
UUID viewerId = UUID.fromString(Session.getUserUUID()); // هر جایی که نگه می‌داری
if ("private".equalsIgnoreCase(entry.getType()) && viewerId != null) {
req.put("viewer_id", viewerId.toString());
}
new Thread(() -> {
JSONObject resp;
try {
resp = org.to.telegramfinalproject.Client.ActionHandler.sendWithResponse(req);
} catch (Exception ex) { ex.printStackTrace(); return; }
if (resp == null || !"success".equalsIgnoreCase(resp.optString("status"))) {
System.err.println("get_header_info failed: " + (resp != null ? resp.optString("message") : "null resp"));
return;
}
JSONObject data = resp.optJSONObject("data");
if (data == null) return;
Platform.runLater(() -> renderHeaderFromData(entry, data));
}).start();
}
private void renderHeaderFromData(ChatEntry entry, JSONObject data) {
String t = entry.getType() == null ? "" : entry.getType().toLowerCase();
switch (t) {
case "private" -> updatePrivateHeader(entry, data);
case "group" -> updateGroupHeader(entry, data);
case "channel" -> updateChannelHeader(entry, data);
default -> chatStatus.setText("");
}
}
private void updatePrivateHeader(ChatEntry entry, JSONObject data) {
String name = nz(data.optString("profile_name", entry.getName()));
chatTitle.setText(name);
// other_user_id برای ریل‌تایم status
String other = data.optString("other_user_id", "");
if (!other.isBlank()) {
try { entry.setOtherUserId(java.util.UUID.fromString(other)); } catch (Exception ignore) {}
}
// تصویر
String img = data.optString("image_url", "");
if (hasVal(img)) {
try {
Image im = AvatarLocalResolver.load(img); // ⬅️
if (im != null) userAvatar.setImage(im);
userAvatar.setClip(new Circle(20, 20, 20));
} catch (Exception ignore) {}
}
chatStatus.setText(userStatusText(
data.optBoolean("online", false),
data.optString("last_seen", null)
));
}
private void updateGroupHeader(ChatEntry entry, JSONObject data) {
chatTitle.setText(nz(data.optString("group_name", entry.getName())));
String img = data.optString("image_url", "");
if (hasVal(img)) {
try {
Image im = AvatarLocalResolver.load(img); // ⬅️
if (im != null) userAvatar.setImage(im);
userAvatar.setClip(new Circle(20, 20, 20));
} catch (Exception ignore) {}
}
int members = data.optInt("member_count", 0);
int online = data.optInt("online_count", -1);
chatStatus.setText(online >= 0 ? (members + " members, " + online + " online")
: (members + " members"));
}
private void updateChannelHeader(ChatEntry entry, JSONObject data) {
chatTitle.setText(nz(data.optString("channel_name", entry.getName())));
String img = data.optString("image_url", "");
if (hasVal(img)) {
try {
Image im = AvatarLocalResolver.load(img); // ⬅️
if (im != null) userAvatar.setImage(im);
userAvatar.setClip(new Circle(20, 20, 20));
} catch (Exception ignore) {}
}
int subs = data.optInt("member_count", 0);
chatStatus.setText(subs + " subscribers");
}
public void onUserStatusChanged(String userUuid, String status, String lastSeenIso) {
if (currentChat == null || !"private".equalsIgnoreCase(currentChat.getType())) return;
var other = currentChat.getOtherUserId();
if (other == null || !other.toString().equalsIgnoreCase(userUuid)) return;
if ("online".equalsIgnoreCase(status)) {
chatStatus.setText("online");
} else {
if (hasVal(lastSeenIso)) {
var ts = parseWhen(lastSeenIso);
chatStatus.setText(ts != null ? ("last seen " + formatWhen(ts)) : "last seen recently");
} else {
chatStatus.setText("last seen recently");
}
}
}
private String userStatusText(boolean online, String lastSeenIso) {
if (online) return "online";
LocalDateTime ts = parseWhen(lastSeenIso);
if (ts == null) return "Last seen recently";
LocalDate today = LocalDate.now();
LocalDate d = ts.toLocalDate();
if (d.isEqual(today)) {
return FMT_HHMM.format(ts);
}
long days = ChronoUnit.DAYS.between(d, today);
if (days > 30) {
return "Last seen long time ago";
}
return "recently";
}
private void setDefaultHeaderAvatarByType(String type){
String path = switch (type == null ? "" : type.toLowerCase()) {
case "channel" -> "/org/to/telegramfinalproject/Avatars/default_channel_profile.png";
case "group" -> "/org/to/telegramfinalproject/Avatars/default_group_profile.png";
default -> "/org/to/telegramfinalproject/Avatars/default_user_profile.png";
};
userAvatar.setImage(new Image(
java.util.Objects.requireNonNull(getClass().getResourceAsStream(path))
));
}
}