add to group or channel real time

This commit is contained in:
2025-09-05 13:12:22 +03:30
parent fd18c75cd9
commit 106cfbbf3d
13 changed files with 644 additions and 300 deletions
+8 -3
View File
@@ -15,8 +15,13 @@ module org.to.telegramfinalproject {
requires spark.core; requires spark.core;
requires javax.servlet.api; requires javax.servlet.api;
requires mp3agic; requires mp3agic;
opens org.to.telegramfinalproject to javafx.fxml;
exports org.to.telegramfinalproject; // FXML کنترلرها در این پکیج‌اند:
exports org.to.telegramfinalproject.Client; opens org.to.telegramfinalproject.UI to javafx.fxml;
// اگر FXML از کلاس‌های Client هم استفاده می‌کند:
opens org.to.telegramfinalproject.Client to javafx.fxml; opens org.to.telegramfinalproject.Client to javafx.fxml;
// فقط اگر لازم است از بیرون به این API‌ها کامپایل شود:
exports org.to.telegramfinalproject.Client;
// ⚠️ عمداً NOT exporting/opens ریشه‌ی پکیج، چون کلاس ندارد.
} }
@@ -138,22 +138,22 @@ public class IncomingMessageListener implements Runnable {
JSONObject finalMsg = msg; JSONObject finalMsg = msg;
JSONObject finalMsg1 = msg; JSONObject finalMsg1 = msg;
switch (action) { switch (action) {
case "added_to_group", "added_to_channel", // case "added_to_group", "added_to_channel",
"removed_from_group", "removed_from_channel", // "removed_from_group", "removed_from_channel",
"chat_deleted", "created_private_chat" -> { // "chat_deleted", "created_private_chat" -> {
// این قسمت مستقل از UI/کنسول است // // این قسمت مستقل از UI/کنسول است
System.out.println("🔄 Chat list changed. Updating..."); // System.out.println("🔄 Chat list changed. Updating...");
Session.forceRefreshChatList = true; // Session.forceRefreshChatList = true;
//
String chatId = msg.getString("chat_id"); // String chatId = msg.getString("chat_id");
String chatType = msg.getString("chat_type"); // String chatType = msg.getString("chat_type");
ActionHandler.requestChatInfo(chatId, chatType); // ActionHandler.requestChatInfo(chatId, chatType);
//
if (action.equals("removed_from_group") || action.equals("removed_from_channel") || action.equals("chat_deleted")) { // if (action.equals("removed_from_group") || action.equals("removed_from_channel") || action.equals("chat_deleted")) {
System.out.println("🚫 You were removed from the chat or chat was deleted. Exiting..."); // System.out.println("🚫 You were removed from the chat or chat was deleted. Exiting...");
ActionHandler.forceExitChat = true; // ActionHandler.forceExitChat = true;
} // }
} // }
// case "chat_updated" -> { // case "chat_updated" -> {
// if (uiMode == UIMode.UI) { // if (uiMode == UIMode.UI) {
@@ -163,7 +163,89 @@ public class IncomingMessageListener implements Runnable {
// } // }
// } // }
case "became_admin", "removed_admin", "ownership_transferred", "admin_permissions_updated" -> { case "added_to_group":
case "added_to_channel":
case "created_private_chat": {
// داده‌ها
UUID chatId = UUID.fromString(msg.getString("chat_id"));
String type = msg.getString("chat_type"); // "group" | "channel" | "private"
String name = msg.optString("name", "");
String imgUrl = msg.optString("image_url", "");
// یک ChatEntry مینیمال بساز (تا UI سریع واکنش بده)
ChatEntry ce = new ChatEntry();
ce.setId(chatId.toString());
ce.setType(type);
ce.setName(name);
ce.setImageUrl(imgUrl);
Platform.runLater(() -> {
var mc = MainController.getInstance();
if (mc == null) return;
// به لیست‌ها اضافه و UI را رفرش می‌کند (متد خودت)
mc.onJoinedOrAdded(ce);
// اگر همین چت الان بازه، مود مناسب را اعمال کن
var cpc = mc.getChatPageController();
if (cpc != null && cpc.isSameChat(chatId, type)) {
// ❗ اگر applyMode در ChatPageController private است،
// یا publicش کن یا این دو خط را حذف کن.
// گروه → NORMAL ، کانال → READ_ONLY (مگر اینکه اجازه پست داشته باشی)
// cpc.applyMode("group".equalsIgnoreCase(type) ? ChatViewMode.NORMAL : ChatViewMode.READ_ONLY);
// cpc.fetchAndRenderHeader(ce); // اختیاری: هدر را تازه کن
}
});
break;
}
case "removed_from_group":
case "removed_from_channel": {
UUID chatId = UUID.fromString(msg.getString("chat_id"));
String type = msg.getString("chat_type");
Platform.runLater(() -> {
var mc = MainController.getInstance();
if (mc == null) return;
removeFromAllLists(chatId);
mc.refreshChatListUI();
// اگر همین چت باز است → به حالت نیاز به Join برگرد
var cpc = mc.getChatPageController();
if (cpc != null && cpc.isSameChat(chatId, type)) {
// اگر applyMode private است، این خط را کامنت کن یا publicش کن
// cpc.applyMode(ChatViewMode.NEEDS_JOIN);
}
});
break;
}
case "chat_deleted": {
UUID chatId = UUID.fromString(msg.getString("chat_id"));
String type = msg.getString("chat_type");
Platform.runLater(() -> {
var mc = MainController.getInstance();
if (mc == null) return;
removeFromAllLists(chatId);
mc.refreshChatListUI();
var cpc = mc.getChatPageController();
if (cpc != null && cpc.isSameChat(chatId, type)) {
// حداقل ورودی را ببندیم/غیرفعال کنیم
// اگر applyMode private است، این خط را کامنت کن یا publicش کن
// cpc.applyMode(ChatViewMode.READ_ONLY);
// و یک پیام سیستمی هم نشان بده
cpc.addSystemMessage("This chat was deleted.");
}
});
break;
}
case "became_admin", "removed_admin", "ownership_transferred", "admin_permissions_updated" : {
System.out.println("🧩 Detected admin/owner role change. Calling handler..."); System.out.println("🧩 Detected admin/owner role change. Calling handler...");
new Thread(() -> { new Thread(() -> {
try { try {
@@ -174,7 +256,7 @@ public class IncomingMessageListener implements Runnable {
}).start(); }).start();
} }
case "new_message" -> { case "new_message" :{
JSONObject data = response.optJSONObject("data"); JSONObject data = response.optJSONObject("data");
if (data == null) break; if (data == null) break;
@@ -198,7 +280,7 @@ public class IncomingMessageListener implements Runnable {
}); });
} }
case "message_edited" -> { case "message_edited": {
JSONObject ui = normalizeMessageId(msg); JSONObject ui = normalizeMessageId(msg);
// (اختیاری) اگر ایونت زمان و چت را هم می‌دهد، می‌توانی چت‌لیست را آپدیت کنی // (اختیاری) اگر ایونت زمان و چت را هم می‌دهد، می‌توانی چت‌لیست را آپدیت کنی
Platform.runLater(() -> { Platform.runLater(() -> {
@@ -208,7 +290,7 @@ public class IncomingMessageListener implements Runnable {
}); });
} }
case "message_deleted_global", "message_deleted_one_sided", "message_deleted" -> { case "message_deleted_global", "message_deleted_one_sided", "message_deleted" : {
JSONObject ui = normalizeMessageId(msg); JSONObject ui = normalizeMessageId(msg);
Platform.runLater(() -> { Platform.runLater(() -> {
var mc = MainController.getInstance(); var mc = MainController.getInstance();
@@ -217,7 +299,7 @@ public class IncomingMessageListener implements Runnable {
}); });
} }
case "message_reacted", "message_unreacted" -> { case "message_reacted", "message_unreacted" : {
JSONObject ui = normalizeMessageId(msg); JSONObject ui = normalizeMessageId(msg);
Platform.runLater(() -> { Platform.runLater(() -> {
var mc = MainController.getInstance(); var mc = MainController.getInstance();
@@ -228,12 +310,12 @@ public class IncomingMessageListener implements Runnable {
case "chat_updated" -> { case "chat_updated": {
var data = response.getJSONObject("data"); var data = response.getJSONObject("data");
bumpChatListFromUpdate(data); bumpChatListFromUpdate(data);
} }
case "user_status_changed" -> { case "user_status_changed" : {
displayRealTimeMessage(action, msg); displayRealTimeMessage(action, msg);
Platform.runLater(() -> { Platform.runLater(() -> {
@@ -250,11 +332,11 @@ public class IncomingMessageListener implements Runnable {
case "blocked_by_user", "unblocked_by_user", "message_seen" -> { case "blocked_by_user", "unblocked_by_user", "message_seen" : {
displayRealTimeMessage(action, msg); displayRealTimeMessage(action, msg);
} }
default -> { default :{
System.out.println("\n❓ Unknown real-time action: " + action); System.out.println("\n❓ Unknown real-time action: " + action);
System.out.println(msg.toString(2)); System.out.println(msg.toString(2));
} }
@@ -550,4 +632,17 @@ public class IncomingMessageListener implements Runnable {
} }
private void removeFromAllLists(UUID chatId) {
if (Session.chatList != null) {
Session.chatList.removeIf(c -> chatId.toString().equals(String.valueOf(c.getId())));
}
if (Session.activeChats != null) {
Session.activeChats.removeIf(c -> chatId.toString().equals(String.valueOf(c.getId())));
}
if (Session.archivedChats != null) {
Session.archivedChats.removeIf(c -> chatId.toString().equals(String.valueOf(c.getId())));
}
}
} }
@@ -133,7 +133,6 @@ public class Session {
resortAndRefresh(); resortAndRefresh();
} }
/** سورت بر اساس lastMessageTime (نزولی) و آپدیت active/archived */
public static void resortAndRefresh() { public static void resortAndRefresh() {
chatList.sort((c1, c2) -> { chatList.sort((c1, c2) -> {
if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0;
@@ -64,6 +64,8 @@ public class ChatPageController {
@FXML @FXML
private ImageView searchIcon; private ImageView searchIcon;
@FXML private VBox blockedPane;
@FXML @FXML
private Button moreButton; // 3-dots button private Button moreButton; // 3-dots button
@FXML @FXML
@@ -114,6 +116,8 @@ public class ChatPageController {
private boolean blockedByMeFlag = false; private boolean blockedByMeFlag = false;
private boolean blockedMeFlag = false; private boolean blockedMeFlag = false;
private volatile boolean justJoinedThisChat = false;
// ===== state ===== // ===== state =====
@@ -372,7 +376,6 @@ public class ChatPageController {
// if (!text.isEmpty()) { // if (!text.isEmpty()) {
// addMessage("You", text); // addMessage("You", text);
// messageInput.clear(); // messageInput.clear();
// // TODO: send to server
// } // }
// } // }
@@ -691,7 +694,7 @@ public class ChatPageController {
// messageContainer.getChildren().add(msg); // messageContainer.getChildren().add(msg);
// } // }
private void addSystemMessage(String content) { public void addSystemMessage(String content) {
Label sys = new Label(content); Label sys = new Label(content);
sys.setStyle("-fx-text-fill: gray; -fx-font-size: 11;"); sys.setStyle("-fx-text-fill: gray; -fx-font-size: 11;");
messageContainer.getChildren().add(sys); messageContainer.getChildren().add(sys);
@@ -699,38 +702,38 @@ public class ChatPageController {
messageScrollPane.setVvalue(1.0); messageScrollPane.setVvalue(1.0);
} }
/** // /**
* Update all header/footer icons according to current theme. // * Update all header/footer icons according to current theme.
*/ // */
private void syncIconsWithTheme() { // private void syncIconsWithTheme() {
boolean dark = themeManager.isDarkMode(); // boolean dark = themeManager.isDarkMode();
// We use “_light” icons on dark backgrounds, and “_dark” on light backgrounds. // // We use “_light” icons on dark backgrounds, and “_dark” on light backgrounds.
String suffix = dark ? "_light.png" : "_dark.png"; // String suffix = dark ? "_light.png" : "_dark.png";
//
// attachment // // attachment
if (attachmentIcon != null) { // if (attachmentIcon != null) {
attachmentIcon.setImage(loadIcon("attachment" + suffix)); // attachmentIcon.setImage(loadIcon("attachment" + suffix));
} // }
// send // // send
if (sendIcon != null) { // if (sendIcon != null) {
sendIcon.setImage(loadIcon("send_cyan2.png")); // sendIcon.setImage(loadIcon("send_cyan2.png"));
} // }
// header icons // // header icons
if (searchIcon != null) { // if (searchIcon != null) {
searchIcon.setImage(loadIcon("search" + suffix)); // searchIcon.setImage(loadIcon("search" + suffix));
} // }
if (moreIcon != null) { // if (moreIcon != null) {
moreIcon.setImage(loadIcon("more" + suffix)); // moreIcon.setImage(loadIcon("more" + suffix));
} // }
//
// header text tint (if youre not fully relying on CSS) // // 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 (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;"); // if (chatStatus != null) chatStatus.setStyle(dark ? "-fx-text-fill:#8ea1b2;" : "-fx-text-fill:#7e8a97;");
//
// View profile icon in more button // // View profile icon in more button
((ImageView) viewProfileItem.getGraphic()) // ((ImageView) viewProfileItem.getGraphic())
.setImage(loadIcon("view_profile" + suffix)); // .setImage(loadIcon("view_profile" + suffix));
} // }
private Image loadIcon(String filename) { private Image loadIcon(String filename) {
var url = getClass().getResource(ICON_BASE + filename); var url = getClass().getResource(ICON_BASE + filename);
@@ -759,7 +762,7 @@ public class ChatPageController {
// } else { // } else {
// setDefaultHeaderAvatarByType(entry.getType()); // setDefaultHeaderAvatarByType(entry.getType());
// } // }
//// userAvatar.setClip(new Circle(20, 20, 20)); //// userAvatar.setClip(new Circle(20, 20, 20));
// AvatarFX.circleClip(userAvatar, 36); // AvatarFX.circleClip(userAvatar, 36);
// //
// //
@@ -800,37 +803,37 @@ public class ChatPageController {
// } // }
public void showChat(ChatEntry entry) { public void showChat(ChatEntry entry) {
this.currentChat = entry; this.currentChat = entry;
this.chatName = entry.getName(); this.chatName = entry.getName();
chatTitle.setText(entry.getName());
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());
}
AvatarFX.circleClip(userAvatar, 36);
// حالت اولیه (بدون انتظار هدر)
if ("channel".equalsIgnoreCase(entry.getType())) {
boolean canPostLocal = entry.isOwner() || entry.isAdmin()
|| (entry.getPermissions()!=null && entry.getPermissions().optBoolean("can_post", false));
applyMode(canPostLocal ? ChatViewMode.NORMAL : ChatViewMode.READ_ONLY);
} else {
applyMode(ChatViewMode.NORMAL);
}
messageContainer.getChildren().clear();
loadMessages(entry);
markAsRead(entry);
// حالا هدر بیاد، دوباره نهایی‌اش می‌کنیم
fetchAndRenderHeader(entry);
chatTitle.setText(entry.getName());
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());
} }
AvatarFX.circleClip(userAvatar, 36);
// حالت اولیه (بدون انتظار هدر)
if ("channel".equalsIgnoreCase(entry.getType())) {
boolean canPostLocal = entry.isOwner() || entry.isAdmin()
|| (entry.getPermissions()!=null && entry.getPermissions().optBoolean("can_post", false));
applyMode(canPostLocal ? ChatViewMode.NORMAL : ChatViewMode.READ_ONLY);
} else {
applyMode(ChatViewMode.NORMAL);
}
messageContainer.getChildren().clear();
loadMessages(entry);
markAsRead(entry);
// حالا هدر بیاد، دوباره نهایی‌اش می‌کنیم
fetchAndRenderHeader(entry);
}
@@ -1175,7 +1178,7 @@ public void showChat(ChatEntry entry) {
// menu.show(row, ev.getScreenX(), ev.getScreenY()); // menu.show(row, ev.getScreenX(), ev.getScreenY());
// ev.consume(); // ev.consume();
// }); // });
//// با کلیک معمولی هم اگر دوست داری: //// با کلیک معمولی هم اگر دوست داری:
// row.setOnMouseClicked(ev -> { // row.setOnMouseClicked(ev -> {
// if (ev.getButton() == javafx.scene.input.MouseButton.PRIMARY && ev.getClickCount() == 1) { // if (ev.getButton() == javafx.scene.input.MouseButton.PRIMARY && ev.getClickCount() == 1) {
// menu.show(row, ev.getScreenX(), ev.getScreenY()); // menu.show(row, ev.getScreenX(), ev.getScreenY());
@@ -1186,110 +1189,110 @@ public void showChat(ChatEntry entry) {
private void addBubble( private void addBubble(
boolean outgoing, boolean outgoing,
String displayName, String displayName,
String type, String type,
String content, String content,
java.time.LocalDateTime sentAt, java.time.LocalDateTime sentAt,
String messageId, String messageId,
String forwardedFrom, String forwardedFrom,
String forwardedBy, String forwardedBy,
String replyToId, String replyToId,
boolean edited, boolean edited,
org.json.JSONArray reactions org.json.JSONArray reactions
) { ) {
// === Meta (نام + زمان) === // === Meta (نام + زمان) ===
String metaText = (displayName == null ? "" : displayName) + "" + formatWhen(sentAt); String metaText = (displayName == null ? "" : displayName) + "" + formatWhen(sentAt);
if (edited) metaText += " (edited)"; if (edited) metaText += " (edited)";
Label meta = new Label(metaText); Label meta = new Label(metaText);
meta.setStyle("-fx-font-size: 11; -fx-text-fill: #7e8a97;"); meta.setStyle("-fx-font-size: 11; -fx-text-fill: #7e8a97;");
meta.setWrapText(true); meta.setWrapText(true);
// برچسب برای آپدیت‌های بعدی (edit) // برچسب برای آپدیت‌های بعدی (edit)
meta.getProperties().put("role", "metaLabel"); meta.getProperties().put("role", "metaLabel");
// === متن/نوع پیام === // === متن/نوع پیام ===
String t = type == null ? "" : type.trim().toUpperCase(); String t = type == null ? "" : type.trim().toUpperCase();
boolean isText = t.isEmpty() ? (content != null && !content.isBlank()) : "TEXT".equals(t); boolean isText = t.isEmpty() ? (content != null && !content.isBlank()) : "TEXT".equals(t);
String bodyText = isText ? (content == null ? "" : content) : bracketLabel(t); String bodyText = isText ? (content == null ? "" : content) : bracketLabel(t);
Label msg = new Label(bodyText); Label msg = new Label(bodyText);
msg.setWrapText(true); msg.setWrapText(true);
msg.setMinHeight(Region.USE_PREF_SIZE); msg.setMinHeight(Region.USE_PREF_SIZE);
// برچسب برای آپدیت‌های بعدی (edit) // برچسب برای آپدیت‌های بعدی (edit)
msg.getProperties().put("role", "msgLabel"); msg.getProperties().put("role", "msgLabel");
// === رنگ بابل‌ها // === رنگ بابل‌ها
boolean dark = themeManager.isDarkMode(); boolean dark = themeManager.isDarkMode();
String mine = dark ? "#2b7cff" : "#d8ecff"; // outgoing (من) String mine = dark ? "#2b7cff" : "#d8ecff"; // outgoing (من)
String theirs = dark ? "#2c333a" : "#f2f4f7"; // incoming (خیلی روشن به‌جای سفید) String theirs = dark ? "#2c333a" : "#f2f4f7"; // incoming (خیلی روشن به‌جای سفید)
String bg = outgoing ? mine : theirs; String bg = outgoing ? mine : theirs;
msg.setStyle( msg.setStyle(
"-fx-background-color:" + bg + ";" + "-fx-background-color:" + bg + ";" +
"-fx-padding:8 12;" + "-fx-padding:8 12;" +
"-fx-background-radius:12;" + "-fx-background-radius:12;" +
"-fx-max-width: 520;" "-fx-max-width: 520;"
); );
// === بدنه‌ی بابل === // === بدنه‌ی بابل ===
VBox bubble = new VBox(4); VBox bubble = new VBox(4);
bubble.getChildren().add(meta); bubble.getChildren().add(meta);
// برچسب‌گذاری بابل برای پیدا کردنش در آپدیت‌های realtime // برچسب‌گذاری بابل برای پیدا کردنش در آپدیت‌های realtime
if (messageId != null && !messageId.isBlank()) { if (messageId != null && !messageId.isBlank()) {
bubble.getProperties().put("messageId", messageId); bubble.getProperties().put("messageId", messageId);
}
// 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) {
Node rxBar = buildReactionsBarFromJson(reactions, dark);
rxBar.getProperties().put("role", "reactionsBar");
bubble.getChildren().add(rxBar);
}
// === ردیف چیدمان راست/چپ ===
HBox row = new HBox(bubble);
row.setFillHeight(true);
row.setSpacing(4);
row.setAlignment(outgoing ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT);
row.setPadding(new Insets(2, 6, 2, 6));
// اضافه به کانتینر
messageContainer.getChildren().add(row);
// ایندکس نود برای آپدیت/حذف realtime
if (messageId != null && !messageId.isBlank()) {
messageNodes.put(messageId, row);
}
boolean isMine = outgoing;
// منوی راست‌کلیک/کلیک (بدون تغییر در ساختار کدت)
ContextMenu menu = buildMessageMenu(isMine, messageId, type, content);
row.setOnContextMenuRequested(ev -> {
menu.show(row, ev.getScreenX(), ev.getScreenY());
ev.consume();
});
row.setOnMouseClicked(ev -> {
if (ev.getButton() == javafx.scene.input.MouseButton.PRIMARY && ev.getClickCount() == 1) {
menu.show(row, ev.getScreenX(), ev.getScreenY());
} }
});
} // 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) {
Node rxBar = buildReactionsBarFromJson(reactions, dark);
rxBar.getProperties().put("role", "reactionsBar");
bubble.getChildren().add(rxBar);
}
// === ردیف چیدمان راست/چپ ===
HBox row = new HBox(bubble);
row.setFillHeight(true);
row.setSpacing(4);
row.setAlignment(outgoing ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT);
row.setPadding(new Insets(2, 6, 2, 6));
// اضافه به کانتینر
messageContainer.getChildren().add(row);
// ایندکس نود برای آپدیت/حذف realtime
if (messageId != null && !messageId.isBlank()) {
messageNodes.put(messageId, row);
}
boolean isMine = outgoing;
// منوی راست‌کلیک/کلیک (بدون تغییر در ساختار کدت)
ContextMenu menu = buildMessageMenu(isMine, messageId, type, content);
row.setOnContextMenuRequested(ev -> {
menu.show(row, ev.getScreenX(), ev.getScreenY());
ev.consume();
});
row.setOnMouseClicked(ev -> {
if (ev.getButton() == javafx.scene.input.MouseButton.PRIMARY && ev.getClickCount() == 1) {
menu.show(row, ev.getScreenX(), ev.getScreenY());
}
});
}
private ContextMenu buildMessageMenu(boolean isMine, String messageId, String type, String content) { private ContextMenu buildMessageMenu(boolean isMine, String messageId, String type, String content) {
ContextMenu menu = new ContextMenu(); ContextMenu menu = new ContextMenu();
@@ -1748,37 +1751,30 @@ private void addBubble(
data.optString("last_seen", null) data.optString("last_seen", null)
)); ));
// ⭐️ بلاک؟ // ❌ هیچ applyMode اینجا نزن!
boolean blocked = data.optBoolean("blocked", false) // حتی اگر blocked آمد، تصمیم مود از بیرون می‌آید.
|| data.optBoolean("is_blocked", false)
|| data.optBoolean("blocked_by_me", false);
if (blocked) {
if (readOnlyLabel != null) readOnlyLabel.setText(""); // فقط UNBLOCK را نشان بده
applyMode(ChatViewMode.BLOCKED);
} else {
applyMode(ChatViewMode.NORMAL);
}
} }
private void updateGroupHeader(ChatEntry entry, JSONObject data) { private void updateGroupHeader(ChatEntry entry, JSONObject data) {
chatTitle.setText(nz(data.optString("group_name", entry.getName()))); chatTitle.setText(nz(data.optString("group_name", entry.getName())));
String img = data.optString("image_url", ""); String img = data.optString("image_url", "");
if (hasVal(img)) { if (hasVal(img)) {
try { try {
Image im = AvatarLocalResolver.load(img); // ⬅️ Image im = AvatarLocalResolver.load(img);
if (im != null) userAvatar.setImage(im); if (im != null) userAvatar.setImage(im);
userAvatar.setClip(new Circle(20, 20, 20)); userAvatar.setClip(new Circle(20, 20, 20));
} catch (Exception ignore) {} } catch (Exception ignore) {}
} }
int members = data.optInt("member_count", 0); int members = data.optInt("member_count", 0);
int online = data.optInt("online_count", -1); int online = data.optInt("online_count", -1);
chatStatus.setText(online >= 0 ? (members + " members, " + online + " online") chatStatus.setText(online >= 0 ? (members + " members, " + online + " online")
: (members + " members")); : (members + " members"));
// ❌ هیچ applyMode اینجا نزن!
} }
// private void updateChannelHeader(ChatEntry entry, JSONObject data) { // private void updateChannelHeader(ChatEntry entry, JSONObject data) {
@@ -1814,14 +1810,8 @@ private void addBubble(
int subs = data.optInt("member_count", 0); int subs = data.optInt("member_count", 0);
chatStatus.setText(subs + " subscribers"); chatStatus.setText(subs + " subscribers");
boolean canPost = canPostToChannel(entry, data); // ❌ هیچ applyMode اینجا نزن!
if (canPost) { // حتی اگر can_post را بده، به مود دست نزن.
applyMode(ChatViewMode.NORMAL);
Platform.runLater(() -> messageInput.requestFocus());
} else {
if (readOnlyLabel != null) readOnlyLabel.setText("YOU CANT SEND MESSAGES IN THIS CHANNEL");
applyMode(ChatViewMode.READ_ONLY);
}
} }
@@ -1876,11 +1866,46 @@ private void addBubble(
)); ));
} }
// @FXML
// private void onJoinClicked() {
// if (currentChat == null) return;
//
// // 1) internal_uuid کاربر فعلی (UUID)
// String myInternalUuid = Session.currentUser != null
// ? Session.currentUser.optString("internal_uuid", "")
// : "";
// if (myInternalUuid.isBlank()) {
// addSystemMessage("Join failed: missing current user internal_uuid.");
// return;
// }
//
// // 2) internal_uuid مقصد (گروه/کانال)
// String targetId = currentChat.getId().toString();
//
// // 3) نوع و نام اکشن
// String t = currentChat.getType();
// String action = "group".equalsIgnoreCase(t) ? "join_group" : "join_channel";
//
// // 4) درخواست طبق قرارداد سرور (کلیدها: user_id = UUID کاربر، id = UUID مقصد)
// JSONObject req = new JSONObject()
// .put("action", action)
// .put("user_id", myInternalUuid) // ← UUID
// .put("id", targetId); // ← UUID گروه/کانال
//
// JSONObject res = ActionHandler.sendWithResponse(req);
// if (res != null && "success".equalsIgnoreCase(res.optString("status"))) {
// MainController.getInstance().onJoinedOrAdded(currentChat);
// applyMode(ChatViewMode.NORMAL);
// Platform.runLater(() -> messageInput.requestFocus());
// } else {
// addSystemMessage("Join failed: " + (res != null ? res.optString("message","") : "no response"));
// }
// }
@FXML @FXML
private void onJoinClicked() { private void onJoinClicked() {
if (currentChat == null) return; if (currentChat == null) return;
// 1) internal_uuid کاربر فعلی (UUID)
String myInternalUuid = Session.currentUser != null String myInternalUuid = Session.currentUser != null
? Session.currentUser.optString("internal_uuid", "") ? Session.currentUser.optString("internal_uuid", "")
: ""; : "";
@@ -1889,27 +1914,71 @@ private void addBubble(
return; return;
} }
// 2) internal_uuid مقصد (گروه/کانال) final String targetId = currentChat.getId().toString();
String targetId = currentChat.getId().toString(); final String t = currentChat.getType();
final String action = "group".equalsIgnoreCase(t) ? "join_group" : "join_channel";
// 3) نوع و نام اکشن
String t = currentChat.getType();
String action = "group".equalsIgnoreCase(t) ? "join_group" : "join_channel";
// 4) درخواست طبق قرارداد سرور (کلیدها: user_id = UUID کاربر، id = UUID مقصد)
JSONObject req = new JSONObject() JSONObject req = new JSONObject()
.put("action", action) .put("action", action)
.put("user_id", myInternalUuid) // UUID .put("user_id", myInternalUuid) // UUID من
.put("id", targetId); // UUID گروه/کانال .put("id", targetId); // UUID مقصد
JSONObject res = ActionHandler.sendWithResponse(req); // ⛔ روی ترد FX کال شبکه نزن!
if (res != null && "success".equalsIgnoreCase(res.optString("status"))) { new Thread(() -> {
MainController.getInstance().onJoinedOrAdded(currentChat); JSONObject res = ActionHandler.sendWithResponse(req);
applyMode(ChatViewMode.NORMAL); boolean ok = (res != null && "success".equalsIgnoreCase(res.optString("status")));
Platform.runLater(() -> messageInput.requestFocus());
} else { Platform.runLater(() -> {
addSystemMessage("Join failed: " + (res != null ? res.optString("message","") : "no response")); if (!ok) {
} addSystemMessage("Join failed: " + (res != null ? res.optString("message","") : "no response"));
return;
}
// برای جلوگیری از داون‌گِرید مود توسط هِدرِ بعدی:
justJoinedThisChat = true;
// اگر MainController نال نبود، لیست چت‌ها را آپدیت کن
var mc = MainController.getInstance();
if (mc != null) {
mc.onJoinedOrAdded(currentChat);
}
// ⚠️ منطق: گروه → همیشه Composer باز شود.
// کانال → اگر اجازه‌ی پست داری، Composer؛ وگرنه READ_ONLY با پیام.
// if ("group".equalsIgnoreCase(t)) {
// applyMode(ChatViewMode.NORMAL);
// messageInput.requestFocus();
// } else if ("channel".equalsIgnoreCase(t)) {
// // اگر می‌خواهی «همان‌جا» Composer فعال شود، باید اجازه‌ی پست را
// // یا از سرور بگیری یا لوکال ست کنی (طبق بیزینس‌لاک‌ت).
// // این‌جا منطقی‌تر: فقط اگر واقعاً اجازه داری.
// boolean canPost =
// (currentChat.isOwner() || currentChat.isAdmin()) ||
// (currentChat.getPermissions()!=null && currentChat.getPermissions().optBoolean("can_post", false));
// if (canPost) {
// applyMode(ChatViewMode.NORMAL);
// messageInput.requestFocus();
// } else {
// if (readOnlyLabel != null)
// readOnlyLabel.setText("YOU CANT SEND MESSAGES IN THIS CHANNEL");
// applyMode(ChatViewMode.READ_ONLY);
// }
// } else {
// applyMode(ChatViewMode.NORMAL);
// messageInput.requestFocus();
// }
if ("group".equalsIgnoreCase(t)) {
applyMode(ChatViewMode.NORMAL);
} else if ("channel".equalsIgnoreCase(t)) {
// تصمیم بیزینسی: ثبت کن.
applyMode(ChatViewMode.READ_ONLY); // یا NORMAL اگر همین را می‌خواهی
}
// هدر را دوباره بگیر (ولی نگذار مود را خراب کند)
fetchAndRenderHeader(currentChat);
});
}).start();
} }
// //
@@ -2041,6 +2110,55 @@ private void addBubble(
// private void applyMode(ChatViewMode mode) {
// currentMode = mode;
//
// boolean normal = (mode == ChatViewMode.NORMAL);
// boolean needsJoin = (mode == ChatViewMode.NEEDS_JOIN);
// boolean needsAdd = (mode == ChatViewMode.NEEDS_ADD_CONTACT);
// boolean readOnly = (mode == ChatViewMode.READ_ONLY);
// boolean blocked = (mode == ChatViewMode.BLOCKED);
//
// // Composer فقط در حالت نرمال
// composerPane.setVisible(normal);
// composerPane.setManaged(normal);
//
// // Join / Add
// joinPane.setVisible(needsJoin);
// joinPane.setManaged(needsJoin);
// addContactPane.setVisible(needsAdd);
// addContactPane.setManaged(needsAdd);
//
// // پنل پایین برای READ_ONLY/BLOCKED
// boolean showRO = readOnly || blocked;
// if (readOnlyPane != null) {
// readOnlyPane.setVisible(showRO);
// readOnlyPane.setManaged(showRO);
// }
//
// // متن آبی برای READ_ONLY
// if (readOnlyLabel != null) {
// readOnlyLabel.setVisible(readOnly);
// readOnlyLabel.setManaged(readOnly);
// }
//
// // دکمهٔ قرمز UNBLOCK فقط در BLOCKED
// if (unblockBtn != null) {
// unblockBtn.setVisible(blocked);
// unblockBtn.setManaged(blocked);
// }
//
// // متن دکمه‌های Join/Add
// if (needsJoin && joinButton != null && currentChat != null) {
// String what = "channel".equalsIgnoreCase(currentChat.getType()) ? "CHANNEL" : "GROUP";
// joinButton.setText(("Join " + what).toUpperCase());
// }
// if (needsAdd && addContactButton != null) {
// addContactButton.setText("ADD CONTACT");
// }
// }
private void applyMode(ChatViewMode mode) { private void applyMode(ChatViewMode mode) {
currentMode = mode; currentMode = mode;
@@ -2050,36 +2168,27 @@ private void addBubble(
boolean readOnly = (mode == ChatViewMode.READ_ONLY); boolean readOnly = (mode == ChatViewMode.READ_ONLY);
boolean blocked = (mode == ChatViewMode.BLOCKED); boolean blocked = (mode == ChatViewMode.BLOCKED);
// Composer فقط در حالت نرمال // فقط در حالت نرمال: کامپوزر
composerPane.setVisible(normal); composerPane.setVisible(normal);
composerPane.setManaged(normal); composerPane.setManaged(normal);
// Join / Add // پنل‌های Join / Add
joinPane.setVisible(needsJoin); joinPane.setVisible(needsJoin);
joinPane.setManaged(needsJoin); joinPane.setManaged(needsJoin);
addContactPane.setVisible(needsAdd); addContactPane.setVisible(needsAdd);
addContactPane.setManaged(needsAdd); addContactPane.setManaged(needsAdd);
// پنل پایین برای READ_ONLY/BLOCKED // پنل‌های پایین
boolean showRO = readOnly || blocked;
if (readOnlyPane != null) { if (readOnlyPane != null) {
readOnlyPane.setVisible(showRO); readOnlyPane.setVisible(readOnly);
readOnlyPane.setManaged(showRO); readOnlyPane.setManaged(readOnly);
}
if (blockedPane != null) {
blockedPane.setVisible(blocked);
blockedPane.setManaged(blocked);
} }
// متن آبی برای READ_ONLY // متن دکمه‌ها
if (readOnlyLabel != null) {
readOnlyLabel.setVisible(readOnly);
readOnlyLabel.setManaged(readOnly);
}
// دکمهٔ قرمز UNBLOCK فقط در BLOCKED
if (unblockBtn != null) {
unblockBtn.setVisible(blocked);
unblockBtn.setManaged(blocked);
}
// متن دکمه‌های Join/Add
if (needsJoin && joinButton != null && currentChat != null) { if (needsJoin && joinButton != null && currentChat != null) {
String what = "channel".equalsIgnoreCase(currentChat.getType()) ? "CHANNEL" : "GROUP"; String what = "channel".equalsIgnoreCase(currentChat.getType()) ? "CHANNEL" : "GROUP";
joinButton.setText(("Join " + what).toUpperCase()); joinButton.setText(("Join " + what).toUpperCase());
@@ -2090,32 +2199,53 @@ private void addBubble(
} }
@FXML @FXML
private void onUnblockClicked() { private void onUnblockClicked() {
if (currentChat == null) return; if (currentChat == null) return;
UUID other = currentChat.getOtherUserId();
if (other == null && currentChat.getDisplayId() == null) return;
final String viewerUuid = org.to.telegramfinalproject.Client.Session.getUserUUID(); // internal_uuid
org.json.JSONObject req = new org.json.JSONObject() if (viewerUuid == null || viewerUuid.isBlank()) {
.put("action", "toggle_block") addSystemMessage("Missing viewer UUID");
.put("user_id", org.to.telegramfinalproject.Client.Session.currentUser.getString("user_id")) return;
.put("target_id", (other != null) ? other.toString() : currentChat.getDisplayId());
org.json.JSONObject res = org.to.telegramfinalproject.Client.ActionHandler.sendWithResponse(req);
boolean ok = (res != null) && ("ok".equalsIgnoreCase(res.optString("status"))
|| "success".equalsIgnoreCase(res.optString("status")));
if (ok) {
applyMode(ChatViewMode.NORMAL);
Platform.runLater(() -> messageInput.requestFocus());
} else {
addSystemMessage("Unblock failed.");
} }
// شبکه روی بک‌گراند
new Thread(() -> {
// 1) اگر otherUserId نداشتیم، از سرور بگیر
java.util.UUID other = currentChat.getOtherUserId();
if (other == null) {
other = resolvePeerUuidFromServer(currentChat);
}
if (other == null) {
Platform.runLater(() -> addSystemMessage("Could not resolve peer UUID."));
return;
}
// 2) حالا درخواست آن‌بلاک
org.json.JSONObject req = new org.json.JSONObject()
.put("action", "toggle_block")
.put("user_id", viewerUuid) // internal_uuid خودت
.put("target_id", other.toString()); // internal_uuid طرف مقابل
org.json.JSONObject res = org.to.telegramfinalproject.Client.ActionHandler.sendWithResponse(req);
boolean ok = res != null && "success".equalsIgnoreCase(res.optString("status"));
Platform.runLater(() -> {
if (ok) {
applyMode(ChatViewMode.NORMAL);
messageInput.requestFocus();
} else {
addSystemMessage("Unblock failed: " + (res != null ? res.optString("message","") : ""));
}
});
}).start();
} }
private boolean canPostToChannel(ChatEntry entry, JSONObject headerData) { private boolean canPostToChannel(ChatEntry entry, JSONObject headerData) {
// 1) اگر سرور صراحتاً can_post داد، همان را بگیر // 1) اگر سرور صراحتاً can_post داد، همان را بگیر
if (headerData != null && headerData.has("can_post")) { if (headerData != null && headerData.has("can_post")) {
@@ -2587,6 +2717,62 @@ private void addBubble(
} }
private void syncIconsWithTheme() {
boolean dark = themeManager.isDarkMode();
String suffix = dark ? "_light.png" : "_dark.png";
if (attachmentIcon != null) attachmentIcon.setImage(loadIcon("attachment" + suffix));
if (sendIcon != null) sendIcon.setImage(loadIcon("send_cyan2.png"));
if (searchIcon != null) searchIcon.setImage(loadIcon("search" + suffix));
if (moreIcon != null) moreIcon.setImage(loadIcon("more" + suffix));
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;");
// 👇 این خط قبلاً ممکن بود NPE بده
if (viewProfileItem != null && viewProfileItem.getGraphic() instanceof ImageView iv) {
iv.setImage(loadIcon("view_profile" + suffix));
}
}
// ChatPageController
private UUID resolvePeerUuidFromServer(ChatEntry chat) {
if (chat == null || !"private".equalsIgnoreCase(chat.getType())) return null;
// اگر از قبل ست شده بود از همون استفاده کن
try {
UUID cached = chat.getOtherUserId();
if (cached != null) return cached;
} catch (Exception ignore) {}
// درخواست به سرور برای گرفتن target_id
org.json.JSONObject req = new org.json.JSONObject()
.put("action", "get_private_chat_target")
.put("chat_id", chat.getId().toString());
// اگر سمت سرور لازم دارد، می‌توانی viewer را هم بفرستی:
// .put("viewer_id", Session.getUserUUID());
org.json.JSONObject res = org.to.telegramfinalproject.Client.ActionHandler.sendWithResponse(req);
if (res == null || !"success".equalsIgnoreCase(res.optString("status"))) return null;
org.json.JSONObject data = res.optJSONObject("data");
if (data == null) return null;
String tid = data.optString("target_id", "");
if (tid == null || tid.isBlank()) return null;
try {
java.util.UUID target = java.util.UUID.fromString(tid);
chat.setOtherUserId(target); // کش محلی کن که دفعات بعد لازم نشه
return target;
} catch (Exception ignore) {
return null;
}
}
@@ -156,7 +156,7 @@ public class MainController {
} }
void refreshChatListUI() { public void refreshChatListUI() {
Platform.runLater(() -> { Platform.runLater(() -> {
chatListContainer.getChildren().clear(); chatListContainer.getChildren().clear();
itemControllers.clear(); itemControllers.clear();
@@ -433,31 +433,81 @@ public class MainController {
} }
} }
// void openChat(ChatEntry chat) {
// try {
// FXMLLoader loader = new FXMLLoader(getClass().getResource("/org/to/telegramfinalproject/Fxml/chat_page.fxml"));
// Node chatPage = loader.load();
//
//
//
//
//
//
//
// ChatPageController controller = loader.getController();
// controller.showChat(chat);
//
// this.chatPageController = controller;
// Session.currentChatId = chat.getId().toString();
//
// chatDisplayArea.getChildren().setAll(chatPage);
// chat.setUnreadCount(0);
// ChatItemController item = itemControllers.get(chat.getId());
// if (item != null) item.setUnread(0);
//
// } catch (IOException ex) {
// ex.printStackTrace();
// }
// }
void openChat(ChatEntry chat) { void openChat(ChatEntry chat) {
try { // فقط برای پرایوت: اول بلاک/بلاک‌شدن را چک کن، بعد مود را تعیین کن
FXMLLoader loader = new FXMLLoader(getClass().getResource("/org/to/telegramfinalproject/Fxml/chat_page.fxml")); if ("private".equalsIgnoreCase(chat.getType())) {
Node chatPage = loader.load(); final String viewerId = org.to.telegramfinalproject.Client.Session.getUserUUID(); // internal_uuid کاربر فعلی
if (viewerId != null && !viewerId.isBlank()) {
new Thread(() -> {
org.json.JSONObject req = new org.json.JSONObject()
.put("action", "check_block_status_by_chat")
.put("viewer_id", viewerId)
.put("chat_id", chat.getId().toString());
org.json.JSONObject res = org.to.telegramfinalproject.Client.ActionHandler.sendWithResponse(req);
boolean blockedByMe = false;
boolean blockedMe = false;
if (res != null && "success".equalsIgnoreCase(res.optString("status"))) {
org.json.JSONObject data = res.optJSONObject("data");
if (data != null) {
// اسم فیلدها را با سرور خودت یکی کن
blockedByMe = data.optBoolean("blocked_by_me", false) || data.optBoolean("is_blocked", false);
blockedMe = data.optBoolean("blocked_me", false);
}
}
final ChatViewMode mode = blockedByMe
? ChatViewMode.BLOCKED
: (blockedMe ? ChatViewMode.READ_ONLY : ChatViewMode.NORMAL);
Platform.runLater(() -> openChatWithMode(chat, mode));
}).start();
return; // نذار پایین دوباره باز شود
ChatPageController controller = loader.getController(); }
controller.showChat(chat);
this.chatPageController = controller;
Session.currentChatId = chat.getId().toString();
chatDisplayArea.getChildren().setAll(chatPage);
chat.setUnreadCount(0);
ChatItemController item = itemControllers.get(chat.getId());
if (item != null) item.setUnread(0);
} catch (IOException ex) {
ex.printStackTrace();
} }
if ("channel".equalsIgnoreCase(chat.getType())) {
boolean canPost =
chat.isOwner() || chat.isAdmin() ||
(chat.getPermissions() != null &&
chat.getPermissions().optBoolean("can_post", false));
// اگر فقط owner/admin ملاک توست، خط بالا را به این تغییر بده:
// boolean canPost = chat.isOwner() || chat.isAdmin();
openChatWithMode(chat, canPost ? ChatViewMode.NORMAL : ChatViewMode.READ_ONLY);
return;
}
// غیرپرایوت یا اگر viewerId نبود → نرمال
openChatWithMode(chat, ChatViewMode.NORMAL);
} }
@@ -56,20 +56,25 @@
-fx-background-color: transparent; -fx-background-color: transparent;
} }
/* لینک‌استایل دکمه‌ها در بنر */
.chat-footer-banner .footer-link-btn {
.button.footer-link-btn {
-fx-background-color: transparent; -fx-background-color: transparent;
-fx-text-fill: #1E88E5; /* آبی پیش‌فرض */
-fx-font-weight: 700;
-fx-padding: 6 12;
-fx-background-insets: 0; -fx-background-insets: 0;
-fx-background-radius: 0;
-fx-border-color: transparent;
-fx-border-width: 0;
-fx-padding: 6 12;
-fx-font-weight: 700;
-fx-cursor: hand; -fx-cursor: hand;
-fx-effect: null;
-fx-text-fill: #1E88E5; /* پیش‌فرض آبی */
} }
.chat-footer-banner .footer-link-btn:hover { .button.footer-link-btn:hover {
-fx-underline: true; -fx-underline: true;
} }
/* نسخه قرمز برای UNBLOCK */ /* نسخه‌ی قرمز برای UNBLOCK */
.chat-footer-banner .footer-link-btn.danger { .button.footer-link-btn.danger {
-fx-text-fill: #D32F2F; /* قرمز */ -fx-text-fill: #D32F2F;
} }
@@ -147,10 +147,14 @@
visible="false" managed="false" styleClass="chat-footer-banner"> visible="false" managed="false" styleClass="chat-footer-banner">
<padding><Insets top="12" right="12" bottom="12" left="12"/></padding> <padding><Insets top="12" right="12" bottom="12" left="12"/></padding>
<Button fx:id="unblockBtn" <Button
fx:id="unblockBtn"
id="unblockAction"
text="UNBLOCK" text="UNBLOCK"
onAction="#onUnblockClicked" onAction="#onUnblockClicked"
styleClass="footer-link-btn danger"/> styleClass="footer-link-btn danger"
style="-fx-background-color: transparent; -fx-background-insets: 0; -fx-background-radius: 0; -fx-border-color: transparent; -fx-border-width: 0; -fx-text-fill: #D32F2F; -fx-font-weight: 700; -fx-padding: 6 12; -fx-effect: null;"/>
</VBox> </VBox>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB