diff --git a/build.gradle b/build.gradle index 96bc6d1..5707d6c 100644 --- a/build.gradle +++ b/build.gradle @@ -44,11 +44,16 @@ dependencies { implementation('net.synedra:validatorfx:0.5.0') { exclude group: 'org.openjfx' } + //For upload files + implementation 'org.slf4j:slf4j-simple:2.0.13' + implementation 'com.sparkjava:spark-core:2.9.4' + implementation 'com.mpatric:mp3agic:0.9.1' //for mp3 implementation 'org.json:json:20231013' implementation 'org.kordamp.ikonli:ikonli-javafx:12.3.1' implementation 'org.kordamp.bootstrapfx:bootstrapfx-core:0.4.0' implementation('eu.hansolo:tilesfx:21.0.3') { exclude group: 'org.openjfx' + } test { diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 0024ec0..274c4f7 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -11,6 +11,10 @@ module org.to.telegramfinalproject { requires eu.hansolo.tilesfx; requires org.json; requires java.sql; + requires java.desktop; + requires spark.core; + requires javax.servlet.api; + requires mp3agic; opens org.to.telegramfinalproject to javafx.fxml; exports org.to.telegramfinalproject; exports org.to.telegramfinalproject.Client; diff --git a/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java b/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java index b2b16ed..9175d22 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java @@ -446,7 +446,31 @@ public class ActionHandler { send(req); } + public void createGroupUI(String id, String name, String url, String user ){ + JSONObject req = new JSONObject(); + req.put("action", "create_group"); + req.put("user_id", user); + req.put("group_id", id); + req.put("group_name", name); + req.put("image_url", url.isBlank() ? JSONObject.NULL : url); + send(req); + + } + + + public void createChannelUI(String id, String name, String url , String user){ + + JSONObject req = new JSONObject(); + req.put("action", "create_channel"); + req.put("user_id", user); + req.put("channel_id", id); + req.put("channel_name", name); + req.put("image_url", url.isBlank() ? JSONObject.NULL : url); + + send(req); + + } public void createChannel() { String channelId = null; @@ -749,7 +773,7 @@ public class ActionHandler { if (existing != null) { openChat(existing); - } else { + } else { ChatEntry preview = new ChatEntry(); preview.setId(String.valueOf(uuid)); preview.setDisplayId(selected.getString("id")); @@ -858,13 +882,13 @@ public class ActionHandler { - break; + break; case "create_group": case "create_channel": if (response.has("data")) { - JSONObject chatJson = response.getJSONObject("data"); + JSONObject chatJson = response.getJSONObject("data"); ChatEntry chat = new ChatEntry( UUID.fromString(chatJson.getString("internal_id")), @@ -879,12 +903,12 @@ public class ActionHandler { refreshChatList(); - System.out.println("✅ Created and opening chat..."); - refreshChatList(); - openChat(chat); - } + System.out.println("✅ Created and opening chat..."); + refreshChatList(); + openChat(chat); + } - break; + break; case "get_messages": JSONArray messages = response.getJSONObject("data").getJSONArray("messages"); @@ -4191,6 +4215,4 @@ public class ActionHandler { } -} - - +} \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/DownloadIndexRegistry.java b/src/main/java/org/to/telegramfinalproject/Client/DownloadIndexRegistry.java new file mode 100644 index 0000000..f89bd8f --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/DownloadIndexRegistry.java @@ -0,0 +1,30 @@ +// DownloadIndexRegistry.java +package org.to.telegramfinalproject.Client; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class DownloadIndexRegistry { + private static final ConcurrentHashMap INSTANCES = new ConcurrentHashMap<>(); + private static volatile boolean HOOK_REGISTERED = false; + + private DownloadIndexRegistry() {} + + public static DownloadsIndex forAccount(UUID accountId) { + registerHookOnce(); + return INSTANCES.computeIfAbsent(accountId, DownloadsIndex::new); + } + + public static void closeAccount(UUID accountId) { + DownloadsIndex idx = INSTANCES.remove(accountId); + if (idx != null) idx.saveQuietly(); + } + + private static synchronized void registerHookOnce() { + if (HOOK_REGISTERED) return; + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + for (DownloadsIndex idx : INSTANCES.values()) idx.saveQuietly(); + })); + HOOK_REGISTERED = true; + } +} \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/DownloadsIndex.java b/src/main/java/org/to/telegramfinalproject/Client/DownloadsIndex.java new file mode 100644 index 0000000..50bdc00 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/DownloadsIndex.java @@ -0,0 +1,116 @@ + +package org.to.telegramfinalproject.Client; + +import org.json.JSONObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class DownloadsIndex { + private final UUID accountId; + private final Path indexFile; + private final Map map = new ConcurrentHashMap<>(); + + public DownloadsIndex(UUID accountId) { + this.accountId = accountId; + this.indexFile = resolveIndexPath(accountId.toString()); + load(); + } + + + private static Path resolveIndexPath(String accountId) { + String os = System.getProperty("os.name", "").toLowerCase(); + String home = System.getProperty("user.home"); + + Path dir; + if (os.contains("win")) { + String appData = System.getenv("APPDATA"); + dir = (appData != null) + ? Paths.get(appData, "TeleSock") + : Paths.get(home, "AppData", "Roaming", "TeleSock"); + } else { + dir = Paths.get(home, ".telesock"); + } + try { Files.createDirectories(dir); } catch (IOException ignored) {} + return dir.resolve("downloads-index-" + accountId + ".json"); + } + + private synchronized void load() { + map.clear(); + try { + if (!Files.exists(indexFile)) return; + String json = Files.readString(indexFile, StandardCharsets.UTF_8); + if (json == null || json.isBlank()) return; + + JSONObject root = new JSONObject(json); + JSONObject items = root.optJSONObject("items"); + if (items == null) return; + + for (String key : items.keySet()) { + JSONObject e = items.getJSONObject(key); + map.put(UUID.fromString(key), new Entry( + e.getString("path"), + e.optLong("size", 0L), + e.optLong("ts", System.currentTimeMillis()) + )); + } + } catch (Exception e) { + System.err.println("⚠️ DownloadsIndex load failed: " + e.getMessage()); + } + } + + private synchronized void save() throws IOException { + JSONObject items = new JSONObject(); + for (Map.Entry it : map.entrySet()) { + JSONObject e = new JSONObject(); + e.put("path", it.getValue().path); + e.put("size", it.getValue().size); + e.put("ts", it.getValue().ts); + items.put(it.getKey().toString(), e); + } + byte[] data = new JSONObject().put("items", items).toString(2).getBytes(StandardCharsets.UTF_8); + + Path tmp = indexFile.resolveSibling(indexFile.getFileName() + ".tmp"); + Files.write(tmp, data); + try { + Files.move(tmp, indexFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(tmp, indexFile, StandardCopyOption.REPLACE_EXISTING); + } + } + + public void saveQuietly() { + try { save(); } catch (Exception ignored) {} + } + + public Path find(UUID mediaKey) { + Entry e = map.get(mediaKey); + if (e == null) return null; + Path p = Paths.get(e.path); + if (Files.exists(p)) return p; + map.remove(mediaKey); + saveQuietly(); + return null; + } + + public void put(UUID mediaKey, Path path, long size) { + map.put(mediaKey, new Entry(path.toString(), size, System.currentTimeMillis())); + saveQuietly(); + } + + public void remove(UUID mediaKey) { + map.remove(mediaKey); + saveQuietly(); + } + + private static final class Entry { + final String path; final long size; final long ts; + Entry(String path, long size, long ts) { + this.path = path; this.size = size; this.ts = ts; + } + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java b/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java index afded31..d617574 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java +++ b/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java @@ -43,7 +43,7 @@ public class IncomingMessageListener implements Runnable { JSONObject response = new JSONObject(line); System.out.println("📥 Received raw line: " + line); - //if it has reqID answer + //if it has reqID answer if (response.has("request_id")) { String requestId = response.getString("request_id"); System.out.println("📬 Response with request_id: " + requestId); @@ -89,16 +89,24 @@ public class IncomingMessageListener implements Runnable { private boolean isRealTimeEvent(String action) { return switch (action) { - case "new_message", "message_edited", "message_deleted_global", - "user_status_changed", "added_to_group", "added_to_channel", + case "new_message", + "message_edited", + "message_deleted_global", "message_deleted_one_sided", "message_deleted", + "message_reacted", "message_unreacted", + "user_status_changed", + "added_to_group", "added_to_channel", "update_group_or_channel", "chat_deleted", "blocked_by_user", "unblocked_by_user", "message_seen", "removed_from_group", "removed_from_channel", - "became_admin", "removed_admin", "ownership_transferred","admin_permissions_updated","created_private_chat" , "message_reacted" , "message_unreacted","chat_updated" -> true; + "became_admin", "removed_admin", "ownership_transferred", + "admin_permissions_updated", + "created_private_chat", + "chat_updated" -> true; default -> false; }; } + void handleRealTimeEvent(JSONObject response) throws IOException { String action = response.getString("action"); JSONObject msg = response.has("data") ? response.getJSONObject("data") : new JSONObject(); @@ -166,6 +174,35 @@ public class IncomingMessageListener implements Runnable { }); } + case "message_edited" -> { + JSONObject ui = normalizeMessageId(msg); + // (اختیاری) اگر ایونت زمان و چت را هم می‌دهد، می‌توانی چت‌لیست را آپدیت کنی + Platform.runLater(() -> { + var mc = MainController.getInstance(); + var chatCtl = (mc != null) ? mc.getChatPageController() : null; + if (chatCtl != null) chatCtl.onRealTimeMessageEdited(ui); + }); + } + + case "message_deleted_global", "message_deleted_one_sided", "message_deleted" -> { + JSONObject ui = normalizeMessageId(msg); + Platform.runLater(() -> { + var mc = MainController.getInstance(); + var chatCtl = (mc != null) ? mc.getChatPageController() : null; + if (chatCtl != null) chatCtl.onRealTimeMessageDeleted(ui); + }); + } + + case "message_reacted", "message_unreacted" -> { + JSONObject ui = normalizeMessageId(msg); + Platform.runLater(() -> { + var mc = MainController.getInstance(); + var chatCtl = (mc != null) ? mc.getChatPageController() : null; + if (chatCtl != null) chatCtl.onRealTimeReaction(ui); + }); + } + + case "chat_updated" -> { var data = response.getJSONObject("data"); @@ -189,8 +226,7 @@ public class IncomingMessageListener implements Runnable { - case "message_edited", "message_deleted_global", "message_reacted", "message_unreacted" - , "blocked_by_user", "unblocked_by_user", "message_seen" -> { + case "blocked_by_user", "unblocked_by_user", "message_seen" -> { displayRealTimeMessage(action, msg); } @@ -478,4 +514,16 @@ public class IncomingMessageListener implements Runnable { } catch (Exception e) { System.err.println("[RT] bumpChatListFromMessage: " + e.getMessage()); } } -} + // --- add this helper --- + private static JSONObject normalizeMessageId(JSONObject j) { + if (j == null) return new JSONObject(); + if (!j.has("message_id") && j.has("id")) { + JSONObject copy = new JSONObject(j.toString()); + copy.put("message_id", copy.optString("id", "")); + return copy; + } + return j; + } + + +} \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/MediaSender.java b/src/main/java/org/to/telegramfinalproject/Client/MediaSender.java new file mode 100644 index 0000000..112fd0d --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/MediaSender.java @@ -0,0 +1,82 @@ +package org.to.telegramfinalproject.Client; + +import org.json.JSONObject; +import java.io.*; +import java.net.Socket; +import java.nio.file.Files; +import java.util.UUID; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; + + + +class MediaSender { + + public static void sendImageOrAudio(Socket socket, + UUID senderId, + String receiverType, + UUID receiverId, + File file, + String messageType, // "IMAGE" یا "AUDIO" + String captionOrEmpty) throws Exception { + + if (!"IMAGE".equals(messageType) && !"AUDIO".equals(messageType)) + throw new IllegalArgumentException("Only IMAGE/AUDIO"); + + // 1) اعلام سوییچ به باینری + PrintWriter textOut = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); + textOut.println("MEDIA"); + + // 2) متادیتا + String mime = Files.probeContentType(file.toPath()); + if (mime == null) mime = "application/octet-stream"; + + Integer width = null, height = null; + if ("IMAGE".equals(messageType)) { + try { + BufferedImage img = ImageIO.read(file); + if (img != null) { width = img.getWidth(); height = img.getHeight(); } + } catch (Exception ignore) {} + } + + UUID messageId = UUID.randomUUID(); + JSONObject header = new JSONObject() + .put("message_id", messageId.toString()) + .put("sender_id", senderId.toString()) + .put("receiver_type", receiverType) + .put("receiver_id", receiverId.toString()) + .put("message_type", messageType) + .put("file_name", file.getName()) + .put("mime_type", mime) + .put("file_size", file.length()) + .put("text", captionOrEmpty == null ? "" : captionOrEmpty); + + if (width != null) header.put("width", width); + if (height != null) header.put("height", height); + + byte[] headerBytes = header.toString().getBytes("UTF-8"); + + // 3) ارسال فریم باینری + DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream())); + dos.writeInt(0x4D444D31); // MAGIC + dos.writeInt(headerBytes.length); // headerLen + dos.write(headerBytes); // header + dos.writeLong(file.length()); // contentLen + + try (InputStream fis = new BufferedInputStream(new FileInputStream(file))) { + byte[] buf = new byte[8192]; + int n; + while ((n = fis.read(buf)) != -1) { + dos.write(buf, 0, n); + } + } + dos.flush(); + + // (اختیاری) Ack متنی + BufferedReader textIn = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8")); + String ack = textIn.readLine(); + if (!"OK".equalsIgnoreCase(ack)) { + throw new IOException("Server did not ACK: " + ack); + } + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Client/Session.java b/src/main/java/org/to/telegramfinalproject/Client/Session.java index a13892c..f4f9475 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/Session.java +++ b/src/main/java/org/to/telegramfinalproject/Client/Session.java @@ -28,7 +28,7 @@ public class Session { public static ChatEntry currentChatEntry = null; public static List contactEntries = new ArrayList<>(); public static boolean inContactListMenu = false; - + public static DownloadsIndex downloadsIndex = null; public static String getUserUUID() { if (currentUser.has("uuid")) return currentUser.getString("uuid"); @@ -110,4 +110,7 @@ public class Session { .filter(ChatEntry::isArchived) .toList(); } + + public void setDownloadIndex(DownloadsIndex idx){ this.downloadsIndex = idx; } + } \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/SidebarHandler.java b/src/main/java/org/to/telegramfinalproject/Client/SidebarHandler.java index dab17f2..2960707 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/SidebarHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Client/SidebarHandler.java @@ -255,234 +255,7 @@ public class SidebarHandler { actionHandler.showContactList(); } -// public void getSavedMessagesData(String userId) { -// try { -// // Step 1: Create the request -// JSONObject request = new JSONObject(); -// request.put("action", "get_saved_messages"); -// request.put("user_id", userId); -// -// // Step 2: Send request and wait for response -// JSONObject response = ActionHandler.sendWithResponse(request); -// -// // Step 3: Check the response -// if (!response.optString("status", "fail").equals("success")) { -// System.out.println("Failed to open Saved Messages chat: " + response.optString("message", "Unknown error")); -// return; -// } -// -// // Step 4: Extract "data" object -// JSONObject data = response.getJSONObject("data"); -// UUID chatId = UUID.fromString(data.getString("chat_id")); -// JSONArray messagesArray = data.getJSONArray("messages"); -// -// // Step 5: Parse messages -// List messages = new ArrayList<>(); -// if (!messagesArray.isEmpty()) { -// for (int i = 0; i < messagesArray.length(); i++) { -// JSONObject msgJson = messagesArray.getJSONObject(i); -// -// // Safely extract optional UUIDs -// UUID replyToId = null; -// String replyToIdStr = msgJson.optString("reply_to_id", null); -// if (replyToIdStr != null && !replyToIdStr.equals("null")) { -// replyToId = UUID.fromString(replyToIdStr); -// } -// -// UUID originalMessageId = null; -// String originalMessageIdStr = msgJson.optString("original_message_id", null); -// if (originalMessageIdStr != null && !originalMessageIdStr.equals("null")) { -// originalMessageId = UUID.fromString(originalMessageIdStr); -// } -// -// UUID forwardedBy = null; -// String forwardedByStr = msgJson.optString("forwarded_by", null); -// if (forwardedByStr != null && !forwardedByStr.equals("null")) { -// forwardedBy = UUID.fromString(forwardedByStr); -// } -// -// UUID forwardedFrom = null; -// String forwardedFromStr = msgJson.optString("forwarded_from", null); -// if (forwardedFromStr != null && !forwardedFromStr.equals("null")) { -// forwardedFrom = UUID.fromString(forwardedFromStr); -// } -// -// Message msg = new Message( -// UUID.fromString(msgJson.getString("message_id")), -// UUID.fromString(msgJson.getString("sender_id")), -// msgJson.getString("receiver_type"), -// UUID.fromString(msgJson.getString("receiver_id")), -// msgJson.getString("content"), -// msgJson.getString("message_type"), -// LocalDateTime.parse(msgJson.getString("send_at").replace(" ", "T")), -// msgJson.getString("status"), -// replyToId, -// msgJson.getBoolean("is_edited"), -// originalMessageId, -// forwardedBy, -// forwardedFrom, -// msgJson.getBoolean("is_deleted_globally"), -// LocalDateTime.parse(msgJson.getString("edited_at").replace(" ", "T")) -// ); -// -// messages.add(msg); -// } -// } -// -// // Step 6: Add to active chats if not already present -// boolean alreadyExists = Session.activeChats.stream() -// .anyMatch(entry -> entry.getId().equals(chatId)); -// if (!alreadyExists) { -// ChatEntry savedEntry = new ChatEntry( -// chatId, -// "Saved-Messages", -// "Saved Messages", -// "📌", // or use a URL string if you have an icon for saved messages -// "private", -// messages.isEmpty() ? null : messages.get(messages.size() - 1).getSend_at() -// ); -// savedEntry.setSavedMessages(true); -// Session.activeChats.add(savedEntry); -// } -// -// // Step 7: Show chat -// showSavedMessages(chatId, messages); -// -// } catch (Exception e) { -// System.out.println("An error occurred while retrieving Saved Messages."); -// e.printStackTrace(); -// } -// } -// private void showSavedMessages(UUID chatId, List messages) { -// Scanner scanner = new Scanner(System.in); -// -// System.out.println("==== Saved Messages ===="); -// if (messages == null || messages.isEmpty()) { -// System.out.println("No messages yet."); -// } else { -// for (Message msg : messages) { -// System.out.println("[" + msg.getSend_at() + "] " + msg.getContent()); -// } -// } -// -// System.out.println("\n(Type your message below, or type 0 to exit)"); -// -// while (true) { -// System.out.print("You: "); -// String content = scanner.nextLine().trim(); -// if (content.equals("0")) { -// System.out.println("Exiting Saved Messages."); -// break; -// } -// -// System.out.print("Enter message type (TEXT / IMAGE / VIDEO / FILE / AUDIO): "); -// String messageType = scanner.nextLine().trim().toUpperCase(); -// Set allowedTypes = Set.of("TEXT", "IMAGE", "VIDEO", "FILE", "AUDIO"); -// while (!allowedTypes.contains(messageType)) { -// System.out.print("Invalid type. Try again (TEXT / IMAGE / VIDEO / FILE / AUDIO): "); -// messageType = scanner.nextLine().trim().toUpperCase(); -// } -// -// // Attachments (اختیاری) -// JSONArray attachmentsArray = new JSONArray(); -// System.out.print("Do you want to attach files? (yes/no): "); -// if (scanner.nextLine().equalsIgnoreCase("yes")) { -// while (true) { -// System.out.print("File URL: "); -// String fileUrl = scanner.nextLine().trim(); -// -// if (fileUrl.isEmpty()) { -// System.out.println("URL can not be empty. Try again."); -// continue; -// } -// if (fileUrl.contains(" ")) { -// System.out.println("URL cannot contain spaces. Try again."); -// continue; -// } -// if (!fileUrl.matches("^(http|https)://.*$")) { -// System.out.println("Invalid URL format. Please enter a valid HTTP/HTTPS link."); -// continue; -// } -// -// System.out.print("File Type (IMAGE / VIDEO / FILE / AUDIO): "); -// String fileType = scanner.nextLine().trim().toUpperCase(); -// Set allowedFileTypes = Set.of("IMAGE", "VIDEO", "FILE", "AUDIO"); -// while (!allowedFileTypes.contains(fileType)) { -// System.out.print("Invalid type. Try again (IMAGE / VIDEO / FILE / AUDIO): "); -// fileType = scanner.nextLine().trim().toUpperCase(); -// } -// -// JSONObject fileJson = new JSONObject(); -// fileJson.put("file_url", fileUrl); -// fileJson.put("file_type", fileType); -// attachmentsArray.put(fileJson); -// -// System.out.print("Add another file? (yes/no): "); -// if (!scanner.nextLine().equalsIgnoreCase("yes")) break; -// } -// } -// -// // درخواست مطابق هندلر send_message -// JSONObject request = new JSONObject(); -// request.put("action", "send_message"); -// request.put("receiver_type", "private"); -// request.put("receiver_id", chatId.toString()); // chat_id -// request.put("content", content); -// request.put("message_type", messageType); -// if (attachmentsArray.length() > 0) { -// request.put("attachments", attachmentsArray); -// } -// -// JSONObject response = ActionHandler.sendWithResponse(request); -// if (!"success".equalsIgnoreCase(response.optString("status"))) { -// System.out.println("Failed to send message: " + response.optString("message", "Unknown error")); -// } else { -// System.out.println("Message sent."); -// -// Message justSent = new Message( -// UUID.fromString(response.getJSONObject("data").getString("message_id")), -// /* senderId */ userUUID, -// /* receiverId */ chatId, -// /* type */ "private", -// /* content */ content, -// /* msgType */ messageType, -// /* send_at */ java.time.LocalDateTime.now() -// ); -// messages.add(justSent); -// System.out.println("[" + justSent.getSend_at() + "] " + justSent.getContent()); -// } -// } -// } - - -// public void openSavedMessages() { -// JSONObject req = new JSONObject().put("action", "get_or_create_saved_messages"); -// JSONObject res = sendWithResponse(req); -// if (res == null || !"success".equals(res.optString("status"))) { -// System.out.println("❌ Could not open Saved Messages: " + res.optString("message","")); -// return; -// } -// -// ActionHandler.requestChatList(); -// String chatId = res.getJSONObject("data").getString("chat_id"); -// -// JSONObject mreq = new JSONObject() -// .put("action", "get_messages") -// .put("receiver_type", "private") -// .put("receiver_id", chatId) -// .put("offset", 0) -// .put("limit", 50); -// -// JSONObject mres = sendWithResponse(mreq); -// JSONArray msgs = (mres != null && mres.has("data")) -// ? mres.getJSONObject("data").optJSONArray("messages") -// : new JSONArray(); -// -// List messages = parseMessages(msgs); // تبدیل JSON → Message -// showSavedMessages(UUID.fromString(chatId), messages); -// } -// @@ -774,7 +547,6 @@ public class SidebarHandler { } private String padBoxLine(String text, int width) { - // عرض: width، دو طرف │ │ final int inner = width - 2; if (text.length() > inner) { text = text.substring(0, inner - 1) + "…"; @@ -804,58 +576,5 @@ public class SidebarHandler { return approved ; } - private List parseMessages(JSONArray msgs) { - List list = new ArrayList<>(); - if (msgs == null) return list; - for (int i = 0; i < msgs.length(); i++) { - try { - JSONObject obj = msgs.getJSONObject(i); - - UUID messageId = UUID.fromString(obj.getString("message_id")); - UUID senderId = UUID.fromString(obj.getString("sender_id")); - String receiverType= obj.getString("receiver_type"); // "private" | "group" | "channel" - UUID receiverId = UUID.fromString(obj.getString("receiver_id")); // برای private = chat_id - String content = obj.optString("content", ""); - String messageType = obj.optString("message_type", "TEXT"); - LocalDateTime sent = LocalDateTime.parse(obj.getString("send_at")); - - Message m = new Message( - messageId, senderId, receiverId, receiverType, content, messageType, sent - ); - - if (obj.has("status") && !obj.isNull("status")) { - try { m.setStatus(obj.getString("status")); } catch (Exception ignore) {} - } - if (obj.has("reply_to_id") && !obj.isNull("reply_to_id")) { - try { m.setReply_to_id(UUID.fromString(obj.getString("reply_to_id"))); } catch (Exception ignore) {} - } - if (obj.has("forwarded_by") && !obj.isNull("forwarded_by")) { - try { m.setForwarded_by(UUID.fromString(obj.getString("forwarded_by"))); } catch (Exception ignore) {} - } - if (obj.has("forwarded_from") && !obj.isNull("forwarded_from")) { - try { m.setForwarded_from(UUID.fromString(obj.getString("forwarded_from"))); } catch (Exception ignore) {} - } - -// // ضمیمه‌ها (اگر در مدل Message متد addAttachment داری) -// if (obj.has("attachments") && !obj.isNull("attachments")) { -// try { -// JSONArray atts = obj.getJSONArray("attachments"); -// for (int j = 0; j < atts.length(); j++) { -// JSONObject a = atts.getJSONObject(j); -// String fileUrl = a.getString("file_url"); -// String fileType = a.getString("file_type"); -// FileAttachment fa = new FileAttachment(fileUrl, fileType); -// try { m.addAttachment(fa); } catch (Exception ignore) {} -// } -// } catch (Exception ignore) {} -// } - - list.add(m); - } catch (Exception perItem) { - perItem.printStackTrace(); - } - } - return list; - } } \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/SocketMediaDownloader.java b/src/main/java/org/to/telegramfinalproject/Client/SocketMediaDownloader.java new file mode 100644 index 0000000..4a43d89 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/SocketMediaDownloader.java @@ -0,0 +1,75 @@ +package org.to.telegramfinalproject.Client; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.PrintWriter; + +public final class SocketMediaDownloader { + private static final int MAGIC_DL = 0x4D444D32; + private final PrintWriter outText; // NEW + private final DataInputStream inBin; + private final DataOutputStream outBin; + + public SocketMediaDownloader(PrintWriter outText, DataInputStream inBin, DataOutputStream outBin) { + this.outText = outText; + this.inBin = inBin; + this.outBin = outBin; + } + + + public java.nio.file.Path download(java.util.UUID mediaKey, java.nio.file.Path saveDir, String fileNameHint) throws Exception { + outText.print("MEDIA_DL\n"); + outText.flush(); + + org.json.JSONObject req = new org.json.JSONObject() + .put("op","download") + .put("media_key", mediaKey.toString()) + .put("offset", 0); + byte[] hb = req.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + + outBin.writeInt(MAGIC_DL); + outBin.writeInt(hb.length); + outBin.write(hb); + outBin.flush(); + + int magic = inBin.readInt(); + if (magic != MAGIC_DL) throw new java.io.IOException("bad magic"); + + int hlen = inBin.readInt(); + byte[] hbytes = inBin.readNBytes(hlen); + org.json.JSONObject hdr = new org.json.JSONObject(new String(hbytes, java.nio.charset.StandardCharsets.UTF_8)); + if (!"success".equalsIgnoreCase(hdr.optString("status"))) { + throw new java.io.IOException("download error: " + hdr.optString("message")); + } + + long contentLen = inBin.readLong(); + String serverName = hdr.optString("file_name", fileNameHint != null ? fileNameHint : mediaKey.toString()); + + java.nio.file.Files.createDirectories(saveDir); + java.nio.file.Path dest = uniquePath(saveDir, serverName); + + try (java.io.OutputStream os = java.nio.file.Files.newOutputStream(dest)) { + byte[] buf = new byte[8192]; + long remain = contentLen; + while (remain > 0) { + int toRead = (int) Math.min(buf.length, remain); + int n = inBin.read(buf, 0, toRead); + if (n == -1) throw new java.io.EOFException("unexpected EOF"); + os.write(buf, 0, n); + remain -= n; + } + } + return dest; + } + + private static java.nio.file.Path uniquePath(java.nio.file.Path dir, String name) throws java.io.IOException { + java.nio.file.Path p = dir.resolve(name); + if (!java.nio.file.Files.exists(p)) return p; + String base = name, ext = ""; + int dot = name.lastIndexOf('.'); + if (dot >= 0) { base = name.substring(0, dot); ext = name.substring(dot); } + int i = 1; + while (java.nio.file.Files.exists(dir.resolve(base + " (" + i + ")" + ext))) i++; + return dir.resolve(base + " (" + i + ")" + ext); + } +} \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java b/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java index eb68156..32eff13 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java +++ b/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java @@ -143,7 +143,7 @@ import java.util.concurrent.LinkedBlockingQueue; public class TelegramClient { private static final String SERVER_HOST = "localhost"; - private static final int SERVER_PORT = 8000; + private static final int SERVER_PORT = 8080; private static TelegramClient instance; @@ -310,5 +310,4 @@ public class TelegramClient { return listener; } -} - +} \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java index 6804ad4..4429721 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java @@ -195,7 +195,7 @@ public class ChannelDatabase { """; try (Connection conn = ConnectionDb.connect()) { - // مرحله اول: ساخت کانال + PreparedStatement stmt = conn.prepareStatement(sql); stmt.setString(1, channel.getChannel_id()); stmt.setString(2, channel.getChannel_name()); @@ -208,9 +208,8 @@ public class ChannelDatabase { if (!rs.next()) return false; UUID internalUUID = (UUID) rs.getObject("internal_uuid"); - channel.setInternal_uuid(internalUUID); // اختیاری برای پیگیری بعدی + channel.setInternal_uuid(internalUUID); - // مرحله دوم: افزودن کاربر به لیست سابسکرایبرها PreparedStatement subStmt = conn.prepareStatement(subscriberSql); subStmt.setObject(1, internalUUID); subStmt.setObject(2, creatorId); diff --git a/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java index af0033d..a377ab4 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java @@ -645,7 +645,7 @@ public class MessageDatabase { (UUID) rs.getObject("forwarded_from"), rs.getBoolean("is_deleted_globally"), (rs.getTimestamp("edited_at") != null) ? rs.getTimestamp("edited_at").toLocalDateTime() : null - ); + ); } @@ -1339,4 +1339,4 @@ public class MessageDatabase { -} +} \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java index d027a5a..65ade2d 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java @@ -1,6 +1,7 @@ package org.to.telegramfinalproject.Database; import org.json.JSONObject; +import org.to.telegramfinalproject.Models.MediaRow; import java.sql.Connection; import java.sql.PreparedStatement; @@ -8,6 +9,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; public class MessageReactionDatabase { @@ -73,4 +75,8 @@ public class MessageReactionDatabase { return counts; // مثال: {"❤️":2,"👍":1} } + + + + } diff --git a/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java index a666c34..6424fdb 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java @@ -388,5 +388,26 @@ public class PrivateChatDatabase { rs.getBoolean("user2_deleted") ); } + public static boolean isParticipant(java.util.UUID chatId, java.util.UUID userId) { + String sql = """ + SELECT 1 + FROM private_chat + WHERE chat_id = ? + AND (user1_id = ? OR user2_id = ?) + LIMIT 1 + """; + try (var c = ConnectionDb.connect(); + var ps = c.prepareStatement(sql)) { + ps.setObject(1, chatId); + ps.setObject(2, userId); + ps.setObject(3, userId); + try (var rs = ps.executeQuery()) { + return rs.next(); + } + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } } diff --git a/src/main/java/org/to/telegramfinalproject/Models/Contact.java b/src/main/java/org/to/telegramfinalproject/Models/Contact.java index d85ae00..1240f21 100644 --- a/src/main/java/org/to/telegramfinalproject/Models/Contact.java +++ b/src/main/java/org/to/telegramfinalproject/Models/Contact.java @@ -19,6 +19,7 @@ public class Contact { this.added_at = LocalDateTime.now(); } + public void setUser_id(UUID user_id){this.user_id = user_id;} public void setContact_id(UUID contact_id){this.contact_id = contact_id;} public void setAdd_at(LocalDateTime add_at){this.added_at =add_at;} diff --git a/src/main/java/org/to/telegramfinalproject/Models/FileAttachment.java b/src/main/java/org/to/telegramfinalproject/Models/FileAttachment.java index 430e457..f3f5d9c 100644 --- a/src/main/java/org/to/telegramfinalproject/Models/FileAttachment.java +++ b/src/main/java/org/to/telegramfinalproject/Models/FileAttachment.java @@ -1,19 +1,162 @@ package org.to.telegramfinalproject.Models; -public class FileAttachment { - private String fileUrl; - private String fileType; +import org.json.JSONObject; +import java.util.Objects; +import java.util.UUID; - public FileAttachment(String fileUrl, String fileType) { +public class FileAttachment { + + private UUID attachmentId; // اختیاری؛ اگر null بود، تولید می‌کنیم + private UUID mediaKey; + private String fileUrl; + private String fileType; // IMAGE, VIDEO, AUDIO, FILE, GIF, STICKER + private String fileName; + private Long fileSize; + private String mimeType; // e.g., image/png + private Integer width; + private Integer height; + private Integer durationSeconds; // for audio/video + private String thumbnailUrl; + private String storagePath; + + public FileAttachment(String fileUrl, + String fileType, + String fileName, + Long fileSize, + String mimeType, + Integer width, + Integer height, + Integer durationSeconds, + String thumbnailUrl) { this.fileUrl = fileUrl; this.fileType = fileType; + this.fileName = fileName; + this.fileSize = fileSize; + this.mimeType = mimeType; + this.width = width; + this.height = height; + this.durationSeconds = durationSeconds; + this.thumbnailUrl = thumbnailUrl; } - public String getFileUrl() { - return fileUrl; + public FileAttachment(String fileUrl, String fileType) { + this(fileUrl, fileType, null, null, null, null, null, null, null); } - public String getFileType() { - return fileType; + public FileAttachment() { + } + + + // ساخت از JSON /upload + public static FileAttachment fromUploadJson(JSONObject j) { + return new FileAttachment( + j.optString("file_url", ""), + j.optString("file_type", "FILE"), + emptyToNull(j.optString("file_name", null)), + j.has("file_size") && !j.isNull("file_size") ? j.getLong("file_size") : null, + emptyToNull(j.optString("mime_type", null)), + j.has("width") && !j.isNull("width") ? j.getInt("width") : null, + j.has("height") && !j.isNull("height") ? j.getInt("height") : null, + j.has("duration_seconds") && !j.isNull("duration_seconds") ? j.getInt("duration_seconds") : null, + j.isNull("thumbnail_url") ? null : emptyToNull(j.optString("thumbnail_url", null)) + ); + } + + public JSONObject toJson() { + JSONObject out = new JSONObject() + .put("file_url", fileUrl) + .put("file_type", fileType); + + out.put("file_name", fileName == null ? JSONObject.NULL : fileName); + out.put("file_size", fileSize == null ? JSONObject.NULL : fileSize); + out.put("mime_type", mimeType == null ? JSONObject.NULL : mimeType); + out.put("width", width == null ? JSONObject.NULL : width); + out.put("height", height == null ? JSONObject.NULL : height); + out.put("duration_seconds", durationSeconds == null ? JSONObject.NULL : durationSeconds); + out.put("thumbnail_url", thumbnailUrl == null ? JSONObject.NULL : thumbnailUrl); + return out; + } + + // Helpers + public boolean isImage() { return "IMAGE".equalsIgnoreCase(fileType) || "GIF".equalsIgnoreCase(fileType); } + public boolean isAudio() { return "AUDIO".equalsIgnoreCase(fileType); } + public boolean hasDimensions() { return width != null && height != null; } + + private static String emptyToNull(String s) { + return (s == null || s.isBlank()) ? null : s; + } + + // Getters + public String getFileUrl() { return fileUrl; } + public String getFileType() { return fileType; } + public String getFileName() { return fileName; } + public Long getFileSize() { return fileSize; } + public String getMimeType() { return mimeType; } + public Integer getWidth() { return width; } + public Integer getHeight() { return height; } + public Integer getDurationSeconds() { return durationSeconds; } + public String getThumbnailUrl() { return thumbnailUrl; } + + // equals/hashCode/toString + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FileAttachment)) return false; + FileAttachment that = (FileAttachment) o; + return Objects.equals(fileUrl, that.fileUrl) && + Objects.equals(fileType, that.fileType) && + Objects.equals(fileName, that.fileName) && + Objects.equals(fileSize, that.fileSize) && + Objects.equals(mimeType, that.mimeType) && + Objects.equals(width, that.width) && + Objects.equals(height, that.height) && + Objects.equals(durationSeconds, that.durationSeconds) && + Objects.equals(thumbnailUrl, that.thumbnailUrl); + } + + @Override public int hashCode() { + return Objects.hash(fileUrl, fileType, fileName, fileSize, mimeType, width, height, durationSeconds, thumbnailUrl); + } + + @Override public String toString() { + return "FileAttachment{" + + "fileUrl='" + fileUrl + '\'' + + ", fileType='" + fileType + '\'' + + ", fileName='" + fileName + '\'' + + ", fileSize=" + fileSize + + ", mimeType='" + mimeType + '\'' + + ", width=" + width + + ", height=" + height + + ", durationSeconds=" + durationSeconds + + ", thumbnailUrl='" + thumbnailUrl + '\'' + + '}'; + } + + public UUID getAttachmentId() {return attachmentId; + } + + public UUID getMediaKey() {return mediaKey; + } + + public String getStoragePath() {return storagePath; + } + + public void setAttachmentId(UUID attachmentId) {this.attachmentId = attachmentId; + } + + public void setMediaKey(UUID mediaKey) {this.mediaKey = mediaKey; + } + + + public void setFileUrl(String fileUrl) {this.fileUrl = fileUrl; + } + public void setFileType(String fileType){this.fileType = fileType;} + public void setFileName(String fileName){this.fileName = fileName;} + public void setFileSize(Long fileSize){this.fileSize = fileSize;} + public void setMimeType(String mimeType){this.mimeType = mimeType;} + public void setWidth(int width){this.width = width;} + public void setHeight(int height){this.height = height;} + public void setDurationSeconds(Integer durationSeconds){this.durationSeconds = durationSeconds;} + public void setThumbnailUrl(String thumbnailUrl){this.thumbnailUrl = thumbnailUrl;} + public void setStoragePath(String storagePath) {this.storagePath = storagePath; } } diff --git a/src/main/java/org/to/telegramfinalproject/Models/MediaRow.java b/src/main/java/org/to/telegramfinalproject/Models/MediaRow.java new file mode 100644 index 0000000..0332ff3 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Models/MediaRow.java @@ -0,0 +1,24 @@ +package org.to.telegramfinalproject.Models; + +import java.util.UUID; + +public class MediaRow { + public UUID messageId; + public String storagePath; + public String fileName; + public String mimeType; + public Long fileSize; + public String receiverType; + public UUID receiverId; + public UUID senderId; + public java.util.UUID attachmentId; + public java.util.UUID mediaKey; + public String fileType; // IMAGE/AUDIO/... + public Integer width; + public Integer height; + public Integer durationSeconds; //for audio only + public String thumbnailUrl; + public String fileUrl; //display link + public String chatType; + public UUID chatId; +} diff --git a/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java b/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java index cccdc4c..84ba9c3 100644 --- a/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java @@ -146,7 +146,7 @@ public class ClientHandler implements Runnable { LocalDateTime lastMessageTime = MessageDatabase.getLastMessageTime(chat.getChat_id(), "private"); - ChatEntry entry = new ChatEntry( + ChatEntry entry = new ChatEntry( chat.getChat_id(), // internal_id = chat_id isSelf ? "Saved Messages" : otherUser.getUser_id(), // id/display isSelf ? "Saved Messages" : otherUser.getProfile_name(), // name @@ -1654,7 +1654,7 @@ public class ClientHandler implements Runnable { } String chatType = requestJson.getString("chat_type"); UUID chatId = UUID.fromString(requestJson.getString("chat_id")); - userId = UUID.fromString(requestJson.getString("user_id")); + userId = UUID.fromString(requestJson.getString("user_id")); boolean success = false; @@ -1723,7 +1723,7 @@ public class ClientHandler implements Runnable { } try { UUID channelId = UUID.fromString(requestJson.getString("channel_id")); - userId = currentUser.getInternal_uuid(); + userId = currentUser.getInternal_uuid(); JSONObject permissions = ChannelDatabase.getChannelPermissions(channelId, userId); @@ -2157,7 +2157,7 @@ public class ClientHandler implements Runnable { } case "send_message" : { - response = handleSendMessage(requestJson); + response = handleSendMessage(requestJson); } break; @@ -2474,8 +2474,8 @@ public class ClientHandler implements Runnable { .put("excerpt", excerpt)); List receivers = Receivers.resolveFor(receiverType, receiverId, senderId); - //RealTimeEventDispatcher.sendNewMessage(message, receivers, "reply", meta); - RealTimeEventDispatcher.sendNewMessageFiltered(message, receivers, senderId, "reply", meta); + receivers = Receivers.resolveFor(receiverType, receiverId, /*exclude*/ null); + RealTimeEventDispatcher.sendNewMessage(message, receivers, "reply", meta); response = saved ? @@ -2532,12 +2532,8 @@ public class ClientHandler implements Runnable { .put("sender_id", original.getSender_id().toString()) .put("sender_name", userDatabase.findByInternalUUID(original.getSender_id()).getProfile_name())); - List receivers = Receivers.resolveFor(targetChatType, targetChatId, currentUser.getInternal_uuid()); - //RealTimeEventDispatcher.sendNewMessage(forwarded, receivers, "forward", meta); - RealTimeEventDispatcher.sendNewMessageFiltered(forwarded, receivers, senderId, "forward", meta); - - } else { - response = new ResponseModel("error", "Failed to forward message."); + List receivers = Receivers.resolveFor(targetChatType, targetChatId, /*exclude*/ null); + RealTimeEventDispatcher.sendNewMessage(forwarded, receivers, "forward", meta); } break; } @@ -2676,7 +2672,7 @@ public class ClientHandler implements Runnable { } case "get_blocked_users": { - userId = currentUser.getInternal_uuid(); + userId = currentUser.getInternal_uuid(); var list = ContactDatabase.getBlockedUsers(userId); org.json.JSONObject data = new org.json.JSONObject(); data.put("blocked_users", list); @@ -2685,7 +2681,7 @@ public class ClientHandler implements Runnable { } case "verify_password": { - userId = currentUser.getInternal_uuid(); + userId = currentUser.getInternal_uuid(); String cur = requestJson.getString("current_password"); User user = userDatabase.findByInternalUUID(userId); boolean ok = PasswordHashing.verify(cur, user.getPassword()); @@ -2696,7 +2692,7 @@ public class ClientHandler implements Runnable { } case "update_username": { - userId = currentUser.getInternal_uuid(); + userId = currentUser.getInternal_uuid(); String cur = requestJson.getString("current_password"); String newUsername = requestJson.getString("new_username"); boolean useBCrypt = true; @@ -2717,7 +2713,7 @@ public class ClientHandler implements Runnable { } case "update_password": { - userId = currentUser.getInternal_uuid(); + userId = currentUser.getInternal_uuid(); String cur = requestJson.getString("current_password"); String newPass = requestJson.getString("new_password"); diff --git a/src/main/java/org/to/telegramfinalproject/Server/MainServer.java b/src/main/java/org/to/telegramfinalproject/Server/MainServer.java index 2318fa3..a7882a3 100644 --- a/src/main/java/org/to/telegramfinalproject/Server/MainServer.java +++ b/src/main/java/org/to/telegramfinalproject/Server/MainServer.java @@ -7,7 +7,8 @@ import java.net.ServerSocket; import java.net.Socket; public class MainServer { - private static final int PORT = 8000; + private static final int PORT = 8080; + public static void main(String[] args) { try (ServerSocket serverSocket = new ServerSocket(PORT)) { diff --git a/src/main/java/org/to/telegramfinalproject/Server/RealTimeEventDispatcher.java b/src/main/java/org/to/telegramfinalproject/Server/RealTimeEventDispatcher.java index db8c6a7..6834bef 100644 --- a/src/main/java/org/to/telegramfinalproject/Server/RealTimeEventDispatcher.java +++ b/src/main/java/org/to/telegramfinalproject/Server/RealTimeEventDispatcher.java @@ -1,10 +1,7 @@ package org.to.telegramfinalproject.Server; import org.json.JSONObject; -import org.to.telegramfinalproject.Database.ChannelDatabase; -import org.to.telegramfinalproject.Database.ContactDatabase; -import org.to.telegramfinalproject.Database.GroupDatabase; -import org.to.telegramfinalproject.Database.userDatabase; +import org.to.telegramfinalproject.Database.*; import org.to.telegramfinalproject.Models.Message; import org.to.telegramfinalproject.Models.User; @@ -12,7 +9,9 @@ import java.io.IOException; import java.io.PrintWriter; import java.net.Socket; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.UUID; public class RealTimeEventDispatcher { @@ -425,4 +424,81 @@ public class RealTimeEventDispatcher { } + public static void notifyChatUpdated(UUID chatId, String chatType, Message lastMsg) { + if (chatId == null || chatType == null) return; + + final String type = chatType.toLowerCase(Locale.ROOT); + + List receivers; + switch (type) { + case "private": + receivers = PrivateChatDatabase.getMembers(chatId); + break; + + case "group": + receivers = GroupDatabase.getMemberUUIDs(chatId); + break; + case "channel": + receivers = ChannelDatabase.getSubscriberUUIDs(chatId); + break; + default: + receivers = Collections.emptyList(); + } + if (receivers == null || receivers.isEmpty()) return; + + // 2) ساخت خلاصه آخرین پیام برای نمایش در لیست چت + String senderName = null; + if (lastMsg != null && lastMsg.getSender_id() != null) { + User u = userDatabase.findByInternalUUID(lastMsg.getSender_id()); + if (u != null) senderName = u.getProfile_name(); + } + + String messageType = lastMsg != null && lastMsg.getMessage_type() != null + ? lastMsg.getMessage_type().toLowerCase(Locale.ROOT) : "text"; + + // preview ساده: برای مدیا، برچسب کوتاه؛ برای متن، کوتاه‌سازی + String preview; + if (!"text".equals(messageType)) { + switch (messageType) { + case "image": preview = "[Photo]"; break; + case "video": preview = "[Video]"; break; + case "audio": preview = "[Audio]"; break; + case "file": preview = "[File]"; break; + default: preview = "[Media]"; + } + } else { + String t = lastMsg != null ? nullToEmpty(lastMsg.getContent()) : ""; + preview = t.length() > 80 ? t.substring(0, 80) + "…" : t; + } + + String sendAt = (lastMsg != null && lastMsg.getSend_at() != null) + ? lastMsg.getSend_at().toString() + : java.time.OffsetDateTime.now().toString(); + + // 3) payload رویداد chat_updated + JSONObject payload = new JSONObject() + .put("action", "chat_updated") + .put("data", new JSONObject() + .put("chat_id", chatId.toString()) + .put("chat_type", type) + .put("last_message", new JSONObject() + .put("id", lastMsg != null ? lastMsg.getMessage_id().toString() : JSONObject.NULL) + .put("sender_id", lastMsg != null ? lastMsg.getSender_id().toString() : JSONObject.NULL) + .put("sender_name", senderName != null ? senderName : JSONObject.NULL) + .put("message_type", messageType) + .put("preview", preview) + .put("send_at", sendAt) + ) + .put("last_message_time", sendAt) + .put("update_reason", "new_message") // برای کلاینت مفید است + ); + + // 4) ارسال به همه اعضای چت + for (UUID uid : receivers) { + sendToUser(uid, payload); + } + } + + private static String nullToEmpty(String s) { return s == null ? "" : s; } + } diff --git a/src/main/java/org/to/telegramfinalproject/Server/TestServer.java b/src/main/java/org/to/telegramfinalproject/Server/TestServer.java new file mode 100644 index 0000000..ddbf0c3 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Server/TestServer.java @@ -0,0 +1,57 @@ +package org.to.telegramfinalproject.Server; + +import org.to.telegramfinalproject.Database.userDatabase; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class TestServer { + private static final int SOCKET_PORT = 8000; // سرور سوکت + private static final int HTTP_PORT = 8080; // سرور آپلود + private static final String UPLOAD_BASE_DIR = "uploads"; // پوشه‌ی ذخیره فایل‌ها + + public static void main(String[] args) { + // 1) استارت HTTP Upload در ترد جدا + Thread httpThread = new Thread(() -> { + try { + UploadHttp.start(HTTP_PORT, UPLOAD_BASE_DIR); + } catch (IOException e) { + System.err.println("Upload HTTP failed to start: " + e.getMessage()); + e.printStackTrace(); + } + }, "upload-http"); + httpThread.setDaemon(true); + httpThread.start(); + + // 2) سرور سوکت با Thread Pool + ExecutorService pool = Executors.newCachedThreadPool(); + try (ServerSocket serverSocket = new ServerSocket(SOCKET_PORT)) { + System.out.println("Socket server started on port " + SOCKET_PORT); + userDatabase.setAllUsersOffline(); + + // 3) Shutdown Hook برای خاموشی تمیز + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("\nShutting down..."); + try { serverSocket.close(); } catch (IOException ignore) {} + pool.shutdownNow(); + userDatabase.setAllUsersOffline(); + System.out.println("Goodbye."); + })); + + // 4) حلقه پذیرش اتصال‌ها + while (!serverSocket.isClosed()) { + Socket clientSocket = serverSocket.accept(); + clientSocket.setTcpNoDelay(true); + System.out.println("New client connected: " + clientSocket.getInetAddress()); + pool.submit(new ClientHandler(clientSocket)); + } + + } catch (IOException e) { + System.err.println("Socket server error: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Server/UploadHttp.java b/src/main/java/org/to/telegramfinalproject/Server/UploadHttp.java new file mode 100644 index 0000000..fd1e77a --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Server/UploadHttp.java @@ -0,0 +1,183 @@ +package org.to.telegramfinalproject.Server; + +import static spark.Spark.*; + +import javax.imageio.ImageIO; +import javax.servlet.MultipartConfigElement; +import javax.servlet.http.Part; + +import java.awt.image.BufferedImage; +import java.io.InputStream; +import java.io.IOException; +import java.nio.file.*; +import java.time.LocalDate; + +import javax.sound.sampled.*; // برای WAV + +import org.json.JSONObject; +import com.mpatric.mp3agic.Mp3File; + +public class UploadHttp { + + public static void start(int httpPort, String baseDir) throws IOException { + port(httpPort); + + Path basePath = Paths.get(baseDir).toAbsolutePath().normalize(); + Files.createDirectories(basePath); + staticFiles.externalLocation(basePath.toString()); + + post("/upload", (req, res) -> { + res.type("application/json"); + try { + long MAX_FILE = 25L * 1024 * 1024; // 25MB + req.attribute("org.eclipse.jetty.multipartConfig", + new MultipartConfigElement("/tmp", MAX_FILE, MAX_FILE, 0)); + + Part filePart = req.raw().getPart("file"); + if (filePart == null || filePart.getSize() == 0) { + res.status(400); + return jsonError("empty file"); + } + if (filePart.getSize() > MAX_FILE) { + res.status(413); + return jsonError("file too large"); + } + + String mime = filePart.getContentType(); + if (mime == null) { + res.status(415); + return jsonError("unknown mime"); + } + + String original = filePart.getSubmittedFileName(); + String ext = guessExt(original, mime); + String day = LocalDate.now().toString(); + String typeDir = subdirFor(mime); // images/audios/files + String subdir = typeDir + "/" + day; + String name = java.util.UUID.randomUUID() + ext; + + Path dir = basePath.resolve(subdir).normalize(); + Files.createDirectories(dir); + Path target = dir.resolve(name).normalize(); + + try (InputStream in = filePart.getInputStream()) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } finally { + filePart.delete(); + } + + String fileUrl = "/" + subdir.replace('\\', '/') + "/" + name; + String fileType = mapToFileType(mime); + + //Meta deta only for audio and image + Integer width = null, height = null, durationSeconds = null; + String thumbnailUrl = null; + + if ("IMAGE".equals(fileType) || "GIF".equals(fileType)) { + int[] wh = imageSize(target); + if (wh != null) { width = wh[0]; height = wh[1]; } + } else if ("AUDIO".equals(fileType)) { + durationSeconds = audioDurationSeconds(target, mime, ext); + } + + res.status(200); + return new JSONObject() + .put("file_url", fileUrl) + .put("file_type", fileType) + .put("file_name", original == null ? "" : safeName(original)) + .put("file_size", Files.size(target)) + .put("mime_type", mime) + .put("width", width == null ? JSONObject.NULL : width) + .put("height", height == null ? JSONObject.NULL : height) + .put("duration_seconds", durationSeconds == null ? JSONObject.NULL : durationSeconds) + .put("thumbnail_url", JSONObject.NULL) + .toString(); + + } catch (Exception e) { + e.printStackTrace(); + res.status(500); + return jsonError("internal error"); + } + }); + + init(); + awaitInitialization(); + System.out.println("Upload HTTP server on http://localhost:" + httpPort + " baseDir=" + basePath); + } + + // ---------- Helpers ---------- + + private static String jsonError(String msg) { + return new JSONObject().put("error", msg).toString(); + } + + private static String subdirFor(String mime) { + String m = mime.toLowerCase(); + if (m.startsWith("image/")) return "images"; + if (m.startsWith("audio/")) return "audios"; + return "files"; + } + + private static String mapToFileType(String mime) { + String m = mime.toLowerCase(); + if (m.startsWith("image/")) { + if (m.contains("gif")) return "GIF"; + return "IMAGE"; + } + if (m.startsWith("audio/")) return "AUDIO"; + return "FILE"; + } + + private static String guessExt(String original, String mime) { + if (original != null && original.contains(".")) { + String ext = original.substring(original.lastIndexOf('.')); + if (ext.length() <= 10) return ext; + } + if ("image/png".equalsIgnoreCase(mime)) return ".png"; + if ("image/jpeg".equalsIgnoreCase(mime)) return ".jpg"; + if ("image/gif".equalsIgnoreCase(mime)) return ".gif"; + if ("audio/mpeg".equalsIgnoreCase(mime)) return ".mp3"; + if ("audio/wav".equalsIgnoreCase(mime) || "audio/x-wav".equalsIgnoreCase(mime)) return ".wav"; + if ("application/pdf".equalsIgnoreCase(mime)) return ".pdf"; + return ""; + } + + private static String safeName(String name) { + return name.replace("\"", "").replace("\n", "").replace("\r", ""); + } + + private static int[] imageSize(Path file) { + try { + BufferedImage bi = ImageIO.read(file.toFile()); + if (bi != null) return new int[]{bi.getWidth(), bi.getHeight()}; + } catch (Exception ignore) {} + return null; + } + + //only audio + private static Integer audioDurationSeconds(Path file, String mime, String ext) { + try { + if ("audio/mpeg".equalsIgnoreCase(mime) || ".mp3".equalsIgnoreCase(ext)) { + Mp3File mp3 = new Mp3File(file.toFile()); + return (int) mp3.getLengthInSeconds(); + } + + // WAV با javax.sound.sampled + if ("audio/wav".equalsIgnoreCase(mime) || "audio/x-wav".equalsIgnoreCase(mime) || ".wav".equalsIgnoreCase(ext)) { + try (AudioInputStream ais = AudioSystem.getAudioInputStream(file.toFile())) { + AudioFormat format = ais.getFormat(); + long frames = ais.getFrameLength(); + if (frames > 0 && format.getFrameRate() > 0) { + double seconds = frames / format.getFrameRate(); + return (int)Math.round(seconds); + } + } + } + } catch (UnsupportedAudioFileException | IOException ignore) { + // فرمت صوتی پشتیبانی نشده برای AudioSystem + } catch (Exception ignore) { + // mp3agic یا سایر استثناها + } + return null; + } +} diff --git a/src/main/java/org/to/telegramfinalproject/UI/ChatPageController.java b/src/main/java/org/to/telegramfinalproject/UI/ChatPageController.java index 45a0000..1767e88 100644 --- a/src/main/java/org/to/telegramfinalproject/UI/ChatPageController.java +++ b/src/main/java/org/to/telegramfinalproject/UI/ChatPageController.java @@ -2,22 +2,25 @@ package org.to.telegramfinalproject.UI; import javafx.application.Platform; import javafx.fxml.FXML; +import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.geometry.Side; +import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.image.Image; import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import javafx.scene.shape.Circle; import javafx.stage.FileChooser; +import javafx.stage.Stage; 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; @@ -25,8 +28,7 @@ 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; +import java.util.*; public class ChatPageController { @@ -75,6 +77,34 @@ public class ChatPageController { @FXML private ImageView sendIcon; + // ===== For Searching System ===== + @FXML private VBox composerPane; + @FXML private VBox joinPane; + @FXML private VBox addContactPane; + + //For search handeling + @FXML private Button joinButton; + @FXML private Button addContactButton; + + //Handle View chat + @FXML private Button unblockBtn; + @FXML private VBox readOnlyPane; + @FXML private Label readOnlyLabel; + + + private ChatViewMode currentMode = ChatViewMode.NORMAL; + + + + // --- state for interactions --- + private String pendingReplyToId = null; // اگه کاربر ریپلای رو زده + private String pendingEditMsgId = null; // اگه کاربر ادیت رو شروع کرده + private final Map messageNodes = new HashMap<>(); + + + private JSONObject lastHeaderData = null; + + // ===== 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"); @@ -341,8 +371,119 @@ public class ChatPageController { // } +// 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, don’t 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 sendMessage() { - // 0) Read and validate input + // 1) متن ورودی String raw = messageInput.getText(); String text = (raw == null) ? "" : raw.trim(); if (text.isEmpty()) return; @@ -352,73 +493,133 @@ public class ChatPageController { return; } - // 1) Clear input immediately for good UX + // 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 + final UUID chatId = currentChat.getId(); + final String cType = currentChat.getType(); - // 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"); + // ========================= + // A) حالت EDIT + // ========================= + if (pendingEditMsgId != null) { + final String msgIdForEdit = pendingEditMsgId; + pendingEditMsgId = null; + + JSONObject req = new JSONObject() + .put("action", "edit_message") + .put("message_id", msgIdForEdit) + .put("new_content", text); + + new Thread(() -> { + JSONObject resp = ActionHandler.sendWithResponse(req); + Platform.runLater(() -> { + if (resp == null || !"success".equalsIgnoreCase(resp.optString("status"))) { + addSystemMessage("Edit failed: " + (resp == null ? "no response" : resp.optString("message",""))); + } else { + // ساده‌ترین راه: پیام‌ها را دوباره بخوان + loadMessages(currentChat); + } + }); + }).start(); + return; + } + + // ========================= + // B) حالت REPLY + // ========================= + if (pendingReplyToId != null) { + final String replyTo = pendingReplyToId; + pendingReplyToId = null; + + // اگر بالای کامپوزر پریویو ریپلای گذاشته‌ای، پاکش کن (اختیاری) + if (!composerPane.getChildren().isEmpty()) { + // اگر عنصر اول preview است، حذف کن + composerPane.getChildren().remove(0); + } + + JSONObject req = new JSONObject() + .put("action", "send_reply_message") + .put("receiver_type", cType) + .put("receiver_id", chatId.toString()) + .put("content", text) + .put("reply_to_id", replyTo); + + new Thread(() -> { + JSONObject resp = ActionHandler.sendWithResponse(req); + Platform.runLater(() -> { + if (resp == null || !"success".equalsIgnoreCase(resp.optString("status"))) { + addSystemMessage("Reply failed: " + (resp == null ? "no response" : resp.optString("message",""))); + } else { + // می‌توانی مثل حالت عادی حباب optimistic بسازی. + // ساده: رفرش لیست پیام‌ها + loadMessages(currentChat); + } + }); + }).start(); + return; + } + + // ========================= + // C) حالت عادی (send_message) + // ========================= + final String contentToSend = text; + + JSONObject req = new JSONObject() + .put("action", "send_message") + .put("receiver_type", cType) + .put("receiver_id", chatId.toString()) + .put("content", contentToSend) + .put("message_type", "TEXT"); - // 4) Send on a background thread new Thread(() -> { - org.json.JSONObject resp; + JSONObject resp; try { - resp = org.to.telegramfinalproject.Client.ActionHandler.sendWithResponse(req); + resp = 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"); + // استخراج message_id و زمان + JSONObject data = resp.optJSONObject("data"); String messageId = null; String sendAtIso = null; if (data != null) { - // If server returns { data: { message_id, send_at, ... } } + // { data: { message_id, send_at } } messageId = data.optString("message_id", null); + sendAtIso = data.optString("send_at", null); - // Some servers nest: { data: { message: {...} } } + // یا { data: { message: {...} } } if (messageId == null) { - org.json.JSONObject msgObj = data.optJSONObject("message"); + 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(); + if (messageId == null) messageId = UUID.randomUUID().toString(); - final java.time.LocalDateTime ts = + final LocalDateTime ts = (sendAtIso != null && !sendAtIso.isBlank()) ? parseWhen(sendAtIso) - : java.time.LocalDateTime.now(); + : LocalDateTime.now(); final String fMessageId = messageId; - final java.time.LocalDateTime fTs = ts; + final LocalDateTime fTs = ts; - // 7) Update UI on FX thread (render outgoing bubble + index for reply previews) Platform.runLater(() -> { - // If user switched chats while sending, don’t render here - if (currentChat == null || !currentChat.getId().equals(targetChatId)) return; + // اگر کاربر چت را عوض کرده بود، چیزی رندر نکن + if (currentChat == null || !currentChat.getId().equals(chatId)) return; + // حباب outgoing addBubble( true, // outgoing "You", // display name @@ -431,15 +632,15 @@ public class ChatPageController { 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); + mc.onChatUpdated(chatId, cType, fTs, /*isIncoming*/ false, preview); } - // Keep it in msgIndex for reply previews - org.json.JSONObject idx = new org.json.JSONObject(); + // برای reply-preview بعدی، پیام را ایندکس کن + JSONObject idx = new JSONObject(); idx.put("message_id", fMessageId); idx.put("message_type", "TEXT"); idx.put("content", contentToSend); @@ -451,6 +652,7 @@ public class ChatPageController { }).start(); } + private void openFileChooser() { FileChooser fc = new FileChooser(); fc.setTitle("Select a file to send"); @@ -533,39 +735,126 @@ public class ChatPageController { return new Image(url.toExternalForm()); } - public void showChat(ChatEntry entry) { +// 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()); +// } + +// +// public void showChat(ChatEntry entry) { +// this.currentChat = entry; +// 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); +// +// fetchAndRenderHeader(entry); +// +// messageContainer.getChildren().clear(); +// loadMessages(entry); +// markAsRead(entry); +// +// applyMode(ChatViewMode.NORMAL); +// +// Platform.runLater(() -> messageInput.requestFocus()); +// } + + +public void showChat(ChatEntry entry) { + this.currentChat = entry; + 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); +} + + + + + public void showChat(ChatEntry entry, ChatViewMode mode) { this.currentChat = entry; + // --- Header --- 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()); - } + 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); - // پیام‌ها + // --- Messages --- messageContainer.getChildren().clear(); loadMessages(entry); markAsRead(entry); - Platform.runLater(() -> messageInput.requestFocus()); - } + // --- حالت UI (Composer / Join / Add Contact) --- + applyMode(mode); + // فوکوس اگر در حالت نرمال هستیم + if (mode == ChatViewMode.NORMAL) { + Platform.runLater(() -> messageInput.requestFocus()); + } + } private void loadMessages(ChatEntry entry) { JSONObject req = new JSONObject(); @@ -752,79 +1041,260 @@ public class ChatPageController { // 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); +// 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" : "#f2f4f7"; +// 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); +// +// // ... inside addBubble(...) after creating 'row' +// 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()); +// } +// }); +// +// } - 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; +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 +) { + // === Meta (نام + زمان) === + 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); + // برچسب برای آپدیت‌های بعدی (edit) + meta.getProperties().put("role", "metaLabel"); - msg.setStyle( - "-fx-background-color:" + bg + ";" + - "-fx-padding:8 12;" + - "-fx-background-radius:12;" + - "-fx-max-width: 520;" - ); - msg.setMinHeight(Region.USE_PREF_SIZE); + // === متن/نوع پیام === + 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); - // بدنه حباب - VBox bubble = new VBox(4); // spacing عمودی داخل حباب - bubble.getChildren().add(meta); + Label msg = new Label(bodyText); + msg.setWrapText(true); + msg.setMinHeight(Region.USE_PREF_SIZE); + // برچسب برای آپدیت‌های بعدی (edit) + msg.getProperties().put("role", "msgLabel"); - // Forward header (اختیاری) - if (hasVal(forwardedFrom) || hasVal(forwardedBy)) { - bubble.getChildren().add(buildForwardHeader(forwardedFrom, forwardedBy)); - } + // === رنگ بابل‌ها + boolean dark = themeManager.isDarkMode(); + String mine = dark ? "#2b7cff" : "#d8ecff"; // outgoing (من) + String theirs = dark ? "#2c333a" : "#f2f4f7"; // incoming (خیلی روشن به‌جای سفید) + String bg = outgoing ? mine : theirs; - // Reply preview (اختیاری) - if (hasVal(replyToId)) { - bubble.getChildren().add(buildReplyBoxFromIndex(replyToId)); - } + msg.setStyle( + "-fx-background-color:" + bg + ";" + + "-fx-padding:8 12;" + + "-fx-background-radius:12;" + + "-fx-max-width: 520;" + ); - // متن اصلی - bubble.getChildren().add(msg); + // === بدنه‌ی بابل === + VBox bubble = new VBox(4); + bubble.getChildren().add(meta); - // 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); + // برچسب‌گذاری بابل برای پیدا کردنش در آپدیت‌های realtime + if (messageId != null && !messageId.isBlank()) { + 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()); + } + }); +} + + private ContextMenu buildMessageMenu(boolean isMine, String messageId, String type, String content) { + ContextMenu menu = new ContextMenu(); + + // --- 2.1 نوار ریکشن بالای منو (مثل تلگرام) --- + HBox reactions = new HBox(8); + String[] emojis = {"👍","👎","😂","😭","🕊️","⚡"}; + for (String e : emojis) { + Button b = new Button(e); + b.getStyleClass().add("reaction-btn"); + b.setOnAction(ae -> { + reactToMessage(messageId, e); + menu.hide(); + }); + reactions.getChildren().add(b); + } + CustomMenuItem reactionsItem = new CustomMenuItem(reactions, false); + reactionsItem.setHideOnClick(false); + menu.getItems().add(reactionsItem); + menu.getItems().add(new SeparatorMenuItem()); + + // --- 2.2 گزینه‌های مشترک --- + MenuItem reply = new MenuItem("Reply"); + reply.setOnAction(ae -> startReply(messageId)); + MenuItem forward = new MenuItem("Forward"); + forward.setOnAction(ae -> startForward(messageId)); + + // (اختیاری) کپی متن فقط برای TEXT + if ("TEXT".equalsIgnoreCase(nz(type)) && hasVal(content)) { + MenuItem copy = new MenuItem("Copy"); + copy.setOnAction(ae -> { + var cb = javafx.scene.input.Clipboard.getSystemClipboard(); + var contentCB = new javafx.scene.input.ClipboardContent(); + contentCB.putString(content); + cb.setContent(contentCB); + }); + menu.getItems().add(copy); + } + + menu.getItems().addAll(reply, forward); + // --- 2.3 فقط برای پیام‌های خودم: Edit/Delete --- + if (isMine) { + MenuItem edit = new MenuItem("Edit"); + edit.setOnAction(ae -> startEdit(messageId, content)); + + MenuItem delete = new MenuItem("Delete"); + delete.getStyleClass().add("danger-item"); + + delete.setOnAction(ae -> confirmDeleteDialog(messageId)); + + menu.getItems().addAll(edit, delete); + } + + + return menu; + } + + private String bracketLabel(String t) { String tt = (t == null) ? "" : t.trim().toUpperCase(); @@ -920,6 +1390,47 @@ public class ChatPageController { && 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(); +// } +// } + + public void onRealTimeNewMessage(JSONObject m) { try { String chatIdStr = str(m,"receiver_id"); @@ -927,15 +1438,17 @@ public class ChatPageController { 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; - } + boolean isCurrent = isSameChat(chatId, chatType); // id → message_id fallback if (!m.has("message_id") && m.has("id")) { m.put("message_id", m.getString("id")); } + String msgId = str(m,"message_id"); + if (!hasVal(msgId)) return; + + // ✅ اگر قبلاً همین پیام داخل UI اضافه شده، دیگه دوباره نساز + if (messageNodes.containsKey(msgId)) return; String senderName = hasVal(str(m,"sender_name")) ? str(m,"sender_name") : (hasVal(str(m,"sender_id")) ? shortId(str(m,"sender_id")) : "Unknown"); @@ -943,23 +1456,150 @@ public class ChatPageController { 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"); + + String fwdFrom = str(m,"forwarded_from"); + String fwdBy = str(m,"forwarded_by"); + String replyTo = str(m,"reply_to_id"); + boolean edited = bool(m,"is_edited"); + JSONArray reacts = arr(m,"reactions"); LocalDateTime ts = parseWhen(whenIso); if (ts == null) ts = LocalDateTime.now(); + // ایندکس برای ریپلای/ادیت/ری‌اکشن‌های بعدی + msgIndex.put(msgId, m); + + // آپدیت لیست چت‌ها (پریویو) + boolean incoming = true; // از سرور آمده → ورودی + updateChatListPreview(chatId, chatType, incoming, content, type); + + // اگر در چت فعلی نیستیم، فقط پریویو آپدیت شد؛ برگرد + if (!isCurrent) return; + + // اضافه کردن حباب بدون رفرش 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); + fwdFrom, fwdBy, replyTo, edited, reacts); + // خوانده شد (در صورت نیاز) if (currentChat != null) markAsRead(currentChat); + } catch (Exception e) { e.printStackTrace(); } } + private void updateChatListPreview(UUID chatId, String type, boolean incoming, String content, String messageType) { + var mc = MainController.getInstance(); + if (mc == null) return; + String preview; + switch ((messageType == null ? "" : messageType.toUpperCase())) { + case "IMAGE" -> preview = (incoming ? "" : "You: ") + "[Image]"; + case "AUDIO" -> preview = (incoming ? "" : "You: ") + "[Audio]"; + case "VIDEO" -> preview = (incoming ? "" : "You: ") + "[Video]"; + case "FILE" -> preview = (incoming ? "" : "You: ") + "[File]"; + default -> preview = (incoming ? "" : "You: ") + (content == null || content.isBlank() ? "[Message]" : content); + } + mc.onChatUpdated(chatId, type, LocalDateTime.now(), incoming, preview); + } + + public void onRealTimeReaction(JSONObject ev) { + String msgId = str(ev, "message_id"); + if (!hasVal(msgId)) return; + + // 1) ایندکس را به‌روزرسانی کن + JSONObject idx = msgIndex.getOrDefault(msgId, new JSONObject().put("message_id", msgId)); + + // اگر «counts» اومد (map emoji→count)، به آرایه تبدیل کن + JSONArray reactions = ev.optJSONArray("reactions"); + if (reactions == null) { + JSONObject counts = ev.optJSONObject("counts"); + if (counts != null) { + reactions = new JSONArray(); + for (String key : counts.keySet()) { + reactions.put(new JSONObject() + .put("emoji", key) + .put("count", counts.optInt(key, 0)) + ); + } + } else if (ev.has("emoji")) { + reactions = new JSONArray().put(new JSONObject() + .put("emoji", ev.optString("emoji","👍")) + .put("count", ev.optInt("count", 1))); + } + } + if (reactions != null) { + idx.put("reactions", reactions); + msgIndex.put(msgId, idx); + } + + // 2) اگر حبابش روی صفحه هست، فقط نوار ری‌اکشن را عوض کن + Node row = messageNodes.get(msgId); + if (!(row instanceof HBox hbox)) return; + + for (Node child : hbox.getChildren()) { + if (child instanceof VBox bubble && msgId.equals(bubble.getProperties().get("messageId"))) { + Node oldBar = null; + for (Node bch : bubble.getChildren()) { + if ("reactionsBar".equals(bch.getProperties().get("role"))) { oldBar = bch; break; } + } + if (oldBar != null) bubble.getChildren().remove(oldBar); + + JSONArray rx = reactions != null ? reactions : idx.optJSONArray("reactions"); + if (rx != null && rx.length() > 0) { + boolean dark = themeManager.isDarkMode(); + Node newBar = buildReactionsBarFromJson(rx, dark); + newBar.getProperties().put("role", "reactionsBar"); + bubble.getChildren().add(newBar); + } + break; + } + } + } + + public void onRealTimeMessageEdited(JSONObject ev) { + String msgId = str(ev, "message_id"); + if (!hasVal(msgId)) return; + + String newContent = str(ev, "new_content"); + + // ایندکس + JSONObject idx = msgIndex.getOrDefault(msgId, new JSONObject().put("message_id", msgId)); + if (hasVal(newContent)) idx.put("content", newContent); + idx.put("is_edited", true); + msgIndex.put(msgId, idx); + + // UI + Node row = messageNodes.get(msgId); + if (!(row instanceof HBox hbox)) return; + + for (Node child : hbox.getChildren()) { + if (child instanceof VBox bubble && msgId.equals(bubble.getProperties().get("messageId"))) { + for (Node bch : bubble.getChildren()) { + Object role = bch.getProperties().get("role"); + if ("msgLabel".equals(role) && bch instanceof Label lbl && hasVal(newContent)) { + lbl.setText(newContent); + } + if ("metaLabel".equals(role) && bch instanceof Label meta) { + String t = meta.getText(); + if (t != null && !t.contains("(edited)")) meta.setText(t + " (edited)"); + } + } + break; + } + } + } + + public void onRealTimeMessageDeleted(JSONObject ev) { + String msgId = str(ev, "message_id"); + if (!hasVal(msgId)) return; + + Node n = messageNodes.remove(msgId); + if (n != null) messageContainer.getChildren().remove(n); + + msgIndex.remove(msgId); + } + + private void fetchAndRenderHeader(ChatEntry entry) { JSONObject req = new JSONObject(); @@ -967,12 +1607,17 @@ public class ChatPageController { 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()); + String viewer = Session.getUserUUID(); // internal_uuid کاربر فعلی + if (viewer != null && !viewer.isBlank()) { + req.put("viewer_id", viewer); } +// // 👇 اضافه کن: آی‌دی کاربر فعلی (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 { @@ -1000,21 +1645,46 @@ public class ChatPageController { } } +// 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 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) {} + try { entry.setOtherUserId(UUID.fromString(other)); } catch (Exception ignore) {} } - // تصویر String img = data.optString("image_url", ""); if (hasVal(img)) { try { - Image im = AvatarLocalResolver.load(img); // ⬅️ + Image im = AvatarLocalResolver.load(img); if (im != null) userAvatar.setImage(im); userAvatar.setClip(new Circle(20, 20, 20)); } catch (Exception ignore) {} @@ -1025,8 +1695,20 @@ public class ChatPageController { data.optString("last_seen", null) )); + // ⭐️ بلاک؟ + boolean blocked = data.optBoolean("blocked", false) + || 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) { chatTitle.setText(nz(data.optString("group_name", entry.getName()))); @@ -1046,23 +1728,51 @@ public class ChatPageController { : (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"); +// } + + 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); // ⬅️ + 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"); + + boolean canPost = canPostToChannel(entry, data); + if (canPost) { + applyMode(ChatViewMode.NORMAL); + Platform.runLater(() -> messageInput.requestFocus()); + } else { + if (readOnlyLabel != null) readOnlyLabel.setText("YOU CAN’T SEND MESSAGES IN THIS CHANNEL"); + applyMode(ChatViewMode.READ_ONLY); + } } + + public void onUserStatusChanged(String userUuid, String status, String lastSeenIso) { if (currentChat == null || !"private".equalsIgnoreCase(currentChat.getType())) return; var other = currentChat.getOtherUserId(); @@ -1112,4 +1822,598 @@ public class ChatPageController { java.util.Objects.requireNonNull(getClass().getResourceAsStream(path)) )); } + + @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 + private void onAddContactClicked() { + if (currentChat == null) return; + + String myUserId = Session.currentUser != null + ? Session.currentUser.optString("user_id", "") + : ""; + + // 2) internal_uuid طرف مقابل + UUID other = currentChat.getOtherUserId(); + if (other == null) { + // اگر otherUserId هنوز نگرفته‌ای، بهتره قبلش از هدر/پروفایل بیاری. + addSystemMessage("Cannot add: other user UUID is missing."); + return; + } + + // 3) درخواست طبق قرارداد سرور + JSONObject req = new JSONObject() + .put("action", "add_contact") + .put("user_id", myUserId) // ← stringِ user_id (غیر UUID) + .put("contact_id", other.toString()); // ← UUID طرف مقابل + + // 4) ارسال + 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("Add contact failed: " + (res != null ? res.optString("message","") : "no response")); + } + } + + +// 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); +// +// composerPane.setVisible(normal); +// composerPane.setManaged(normal); +// +// joinPane.setVisible(needsJoin); +// joinPane.setManaged(needsJoin); +// +// addContactPane.setVisible(needsAdd); +// addContactPane.setManaged(needsAdd); +// +// if (needsJoin && joinButton != null && currentChat != null) { +// String what = "channel".equalsIgnoreCase(currentChat.getType()) ? "CHANNEL" : "GROUP"; +// joinButton.setText(("Join " + what).toUpperCase()); // => JOIN CHANNEL / JOIN GROUP +// } +// if (needsAdd && addContactButton != null) { +// addContactButton.setText("ADD CONTACT"); +// } +// } + + + + 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"); + } + } + + + @FXML + private void onUnblockClicked() { + if (currentChat == null) return; + UUID other = currentChat.getOtherUserId(); + if (other == null && currentChat.getDisplayId() == null) return; + + // ⚠️ با API خودت هماهنگ کن: + // این یک الگوی معمول است: user_id (کاربر فعلی) + contact_id (کسی که بلاک شده) + org.json.JSONObject req = new org.json.JSONObject() + .put("action", "unblock_user") // یا "unblock_contact" طبق سرور + .put("user_id", org.to.telegramfinalproject.Client.Session.currentUser.getString("user_id")) + .put("contact_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."); + } + } + + + + private boolean canPostToChannel(ChatEntry entry, JSONObject headerData) { + // 1) اگر سرور صراحتاً can_post داد، همان را بگیر + if (headerData != null && headerData.has("can_post")) { + return headerData.optBoolean("can_post", false); + } + // 2) یا اگر is_owner / is_admin را داد + if (headerData != null && (headerData.has("is_owner") || headerData.has("is_admin"))) { + return headerData.optBoolean("is_owner", false) || headerData.optBoolean("is_admin", false); + } + // 3) فال‌بک به اطلاعات لوکال: ChatEntry + permissions محلی + if (entry != null) { + if (entry.isOwner() || entry.isAdmin()) return true; + if (entry.getPermissions() != null && entry.getPermissions().optBoolean("can_post", false)) { + return true; + } + } + return false; + } + + + + private void reactToMessage(String msgId, String emoji) { + JSONObject req = new JSONObject() + .put("action", "react_to_message") + .put("message_id", msgId) + .put("reaction", emoji); + + new Thread(() -> { + JSONObject res = ActionHandler.sendWithResponse(req); + Platform.runLater(() -> { + if (res == null || !"success".equalsIgnoreCase(res.optString("status"))) { + addSystemMessage("Failed to react."); + } else { + // ساده‌ترین کار: رفرش + loadMessages(currentChat); + } + }); + }).start(); + } + + private void startReply(String msgId) { + pendingReplyToId = msgId; + // یک پریویو کوچیک بالای TextArea نشان بده (می‌تونی از buildReplyBoxFromIndex استفاده کنی) + var preview = buildReplyBoxFromIndex(msgId); + if (!composerPane.getChildren().contains(preview)) { + composerPane.getChildren().add(0, preview); + } + messageInput.requestFocus(); + } + + private void startForward(String originalMsgId) { + openForwardPickerFromSession(originalMsgId); + } + + private void openForwardPickerFromSession(String originalMsgId) { + java.util.List targets = fetchForwardTargetsFromSession(); + + // اگر نخواستی به همین چت فعلی هم اجازه بدی، حذفش کن: + if (currentChat != null) { + targets.removeIf(t -> + t.id.equals(currentChat.getId()) && + t.type.equalsIgnoreCase(currentChat.getType()) + ); + } + + Dialog dialog = new Dialog<>(); + dialog.setTitle("Forward message"); + if (messageContainer != null && messageContainer.getScene() != null) { + dialog.initOwner(messageContainer.getScene().getWindow()); + } + + ButtonType btnSend = new ButtonType("Send", ButtonBar.ButtonData.OK_DONE); + ButtonType btnCancel = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + dialog.getDialogPane().getButtonTypes().setAll(btnSend, btnCancel); + + // آیکن/گرافیک (اگر آیکن forward داری) + try { + var iv = new ImageView(new Image( + getClass().getResourceAsStream("/org/to/telegramfinalproject/Icons/ic_forward.png") + )); + iv.setFitWidth(18); iv.setFitHeight(18); + dialog.getDialogPane().setGraphic(iv); + } catch (Exception ignore) {} + + TextField search = new TextField(); + search.setPromptText("Search chats…"); + + var base = javafx.collections.FXCollections.observableArrayList(targets); + var filtered = new javafx.collections.transformation.FilteredList<>(base, _x -> true); + + search.textProperty().addListener((obs, ov, nv) -> { + String q = nv == null ? "" : nv.trim().toLowerCase(); + filtered.setPredicate(t -> { + if (q.isEmpty()) return true; + return t.name.toLowerCase().contains(q) || t.type.toLowerCase().contains(q); + }); + }); + + ListView listView = new ListView<>(filtered); + listView.setPrefHeight(360); + listView.setCellFactory(lv -> new ListCell<>() { + private final HBox root = new HBox(10); + private final ImageView avatar = new ImageView(); + private final VBox texts = new VBox(2); + private final Label title = new Label(); + private final Label subtitle = new Label(); + + { + avatar.setFitWidth(28); + avatar.setFitHeight(28); + root.setAlignment(Pos.CENTER_LEFT); + subtitle.setStyle("-fx-font-size: 11; -fx-text-fill: #7e8a97;"); + texts.getChildren().addAll(title, subtitle); + root.getChildren().addAll(avatar, texts); + } + + @Override protected void updateItem(ForwardTarget item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setGraphic(null); + } else { + Image img = null; + if (item.imageUrl != null && !item.imageUrl.isEmpty()) { + img = org.to.telegramfinalproject.Client.AvatarLocalResolver.load(item.imageUrl); + } + if (img == null) { + String fallback = switch (item.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"; + }; + img = new Image(getClass().getResourceAsStream(fallback)); + } + avatar.setImage(img); + + title.setText(item.name.isBlank() ? item.id.toString() : item.name); + subtitle.setText(item.type.toUpperCase()); + + setGraphic(root); + } + } + }); + + dialog.setOnShown(ev -> { + Button sendBtn = (Button) dialog.getDialogPane().lookupButton(btnSend); + sendBtn.setDisable(true); + listView.getSelectionModel().selectedItemProperty().addListener((o, ov, nv) -> { + sendBtn.setDisable(nv == null); + }); + listView.setOnMouseClicked(me -> { + if (me.getClickCount() == 2 && listView.getSelectionModel().getSelectedItem() != null) { + sendBtn.fire(); + } + }); + }); + + VBox content = new VBox(10, search, listView); + content.setPadding(new Insets(12)); + dialog.getDialogPane().setContent(content); + + dialog.setResultConverter(bt -> { + if (bt == btnSend) return listView.getSelectionModel().getSelectedItem(); + return null; + }); + + var result = dialog.showAndWait(); + result.ifPresent(target -> forwardToTarget(originalMsgId, target)); + } + + + private void forwardToTarget(String originalMsgId, ForwardTarget target) { + if (target == null) return; + + // (اختیاری) قبل از ارسال، محدودیت‌ها را چک کن + // مثلا کانال‌هایی که اجازه‌ی پست نداری: + // if ("channel".equalsIgnoreCase(target.type) && !/*canPost*/ false) { addSystemMessage("You can’t post to this channel."); return; } + + JSONObject req = new JSONObject() + .put("action", "forward_message") + .put("original_message_id", originalMsgId) + .put("target_chat_id", target.id.toString()) + .put("target_chat_type", target.type); + + new Thread(() -> { + JSONObject res = ActionHandler.sendWithResponse(req); + Platform.runLater(() -> { + if (res == null || !"success".equalsIgnoreCase(res.optString("status"))) { + addSystemMessage("Forward failed: " + (res == null ? "" : res.optString("message",""))); + } else { + // اگر مقصد همین چت بود، لیست پیام‌ها را رفرش کن + if (currentChat != null && + currentChat.getId().equals(target.id) && + currentChat.getType().equalsIgnoreCase(target.type)) { + loadMessages(currentChat); + } else { + addSystemMessage("Forwarded to " + (target.name.isBlank() ? target.id : target.name)); + } + } + }); + }).start(); + } + + + private void startEdit(String msgId, String currentText) { + pendingEditMsgId = msgId; + messageInput.setText(currentText == null ? "" : currentText); + messageInput.requestFocus(); + messageInput.positionCaret(messageInput.getText().length()); + } + private void confirmDelete(String msgId) { + Alert a = new Alert(Alert.AlertType.CONFIRMATION); + a.setHeaderText("Delete message?"); + ButtonType onlyMe = new ButtonType("Delete for me"); + ButtonType everyone = new ButtonType("Delete for everyone"); + ButtonType cancel = ButtonType.CANCEL; + + // نمایش «Delete for everyone» فقط اگر منطقی به‌نظر می‌رسد + boolean showGlobal = true; // ساده: بذار سرور رد کند اگر مجاز نیست + if (showGlobal) a.getButtonTypes().setAll(onlyMe, everyone, cancel); + else a.getButtonTypes().setAll(onlyMe, cancel); + + a.showAndWait().ifPresent(btn -> { + if (btn == onlyMe) deleteMessage(msgId, "one-sided"); + else if (btn == everyone) deleteMessage(msgId, "global"); + }); + } + + private void deleteMessage(String msgId, String deleteType) { + JSONObject req = new JSONObject() + .put("action", "delete_message") + .put("message_id", msgId) + .put("delete_type", deleteType); + + new Thread(() -> { + JSONObject res = ActionHandler.sendWithResponse(req); + Platform.runLater(() -> { + if (res == null || !"success".equalsIgnoreCase(res.optString("status"))) { + addSystemMessage("Delete failed: " + (res==null?"":res.optString("message"))); + return; + } + // one-sided: سریعاً از UI حذف کن + if ("one-sided".equals(deleteType)) { + Node n = messageNodes.remove(msgId); + if (n != null) messageContainer.getChildren().remove(n); + } else { + // global: سرور RT می‌فرستد، اما برای UX می‌توانی رفرش کنی + loadMessages(currentChat); + } + }); + }).start(); + } + + + // پیام مالِ من است؟ + private boolean isOutgoingMessage(String messageId) { + if (Session.currentUser == null || !Session.currentUser.has("internal_uuid")) return false; + String meId = Session.currentUser.optString("internal_uuid", ""); + JSONObject m = msgIndex.get(messageId); + if (m == null) return false; + return meId.equalsIgnoreCase(m.optString("sender_id", "")); + } + + // در کانال می‌تونم global حذف کنم؟ + private boolean canDeleteInChannel() { + if (currentChat == null) return false; + if (currentChat.isOwner() || currentChat.isAdmin()) return true; + return currentChat.getPermissions()!=null && + currentChat.getPermissions().optBoolean("can_delete", false); + } + + + + + private void confirmDeleteDialog(String messageId) { + boolean outgoing = isOutgoingMessage(messageId); + String t = currentChat != null ? currentChat.getType() : ""; + boolean canGlobal = + "private".equalsIgnoreCase(t) || "group".equalsIgnoreCase(t) ? outgoing + : "channel".equalsIgnoreCase(t) ? canDeleteInChannel() + : false; + + String peerName = (currentChat != null && currentChat.getName()!=null) + ? currentChat.getName() + : "everyone"; + + Dialog dialog = new Dialog<>(); + dialog.setTitle("Delete message"); + + // مالک دیالوگ (اختیاری ولی بهتر) + if (messageContainer != null && messageContainer.getScene() != null) { + dialog.initOwner(messageContainer.getScene().getWindow()); + } + + // دکمه‌ها + ButtonType btnCancel = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + ButtonType btnDelete = new ButtonType("Delete", ButtonBar.ButtonData.OK_DONE); + dialog.getDialogPane().getButtonTypes().setAll(btnDelete, btnCancel); // Delete اول بیاید + + // عنوان و چک‌باکس + Label title = new Label("Do you want to delete this message?"); + title.setStyle("-fx-font-size: 14; -fx-font-weight: bold; -fx-text-fill: -fx-text-base-color;"); + + CheckBox alsoDelete = new CheckBox("Also delete for " + peerName); + alsoDelete.setSelected(false); + alsoDelete.setVisible(canGlobal); + alsoDelete.setManaged(canGlobal); + + VBox box = new VBox(10, title, alsoDelete); + box.setPadding(new Insets(12, 12, 6, 12)); + dialog.getDialogPane().setContent(box); + + Image trashImg = new Image(getClass().getResourceAsStream( + "/org/to/telegramfinalproject/Icons/ic_delete_danger.png" + )); + ImageView trashIv = new ImageView(trashImg); + trashIv.setFitWidth(18); + trashIv.setFitHeight(18); + dialog.getDialogPane().setGraphic(trashIv); + + // آیکن خود پنجره (بالا-چپ فریم) + dialog.getDialogPane().sceneProperty().addListener((obs, oldScene, newScene) -> { + if (newScene != null) { + Stage stage = (Stage) newScene.getWindow(); + stage.getIcons().setAll(trashImg); + } + }); + + // کمی استایل + dialog.getDialogPane().setStyle(""" + -fx-background-radius: 12; + -fx-background-insets: 0; + -fx-padding: 8; + """); + + + dialog.setOnShown(ev -> { + Button btnDel = (Button) dialog.getDialogPane().lookupButton(btnDelete); + if (btnDel != null) { + btnDel.getStyleClass().add("tg-btn-danger"); + } + Button btnCan = (Button) dialog.getDialogPane().lookupButton(btnCancel); + if (btnCan != null) { + btnCan.getStyleClass().add("tg-btn-secondary"); + } + }); + + // نمایش و تصمیم + var res = dialog.showAndWait(); + if (res.isPresent() && res.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) { + String deleteType = (alsoDelete.isSelected() && canGlobal) ? "global" : "one-sided"; + deleteMessage(messageId, deleteType); + } + } + + + + private static final class ForwardTarget { + final UUID id; // internal_id + final String type; // private | group | channel + final String name; // title + final String imageUrl; // optional + + ForwardTarget(UUID id, String type, String name, String imageUrl) { + this.id = id; + this.type = type == null ? "" : type; + this.name = name == null ? "" : name; + this.imageUrl = imageUrl == null ? "" : imageUrl; + } + + @Override public String toString() { + return name + " (" + type + ")"; + } + } + + + private java.util.List fetchForwardTargetsFromSession() { + java.util.LinkedHashMap map = new java.util.LinkedHashMap<>(); + + org.json.JSONObject cu = org.to.telegramfinalproject.Client.Session.currentUser; + if (cu == null) return new java.util.ArrayList<>(); + + // دو منبع معمول در Session: chat_list و active_chat_list + org.json.JSONArray[] sources = new org.json.JSONArray[]{ + cu.optJSONArray("chat_list"), + cu.optJSONArray("active_chat_list") + }; + + for (org.json.JSONArray arr : sources) { + if (arr == null) continue; + for (int i = 0; i < arr.length(); i++) { + org.json.JSONObject o = arr.optJSONObject(i); + if (o == null) continue; + String internalId = o.optString("internal_id", ""); + String type = o.optString("type", ""); + String name = o.optString("name", ""); + String imageUrl = o.optString("image_url", ""); + + if (internalId.isBlank() || type.isBlank()) continue; + + UUID id; + try { id = java.util.UUID.fromString(internalId); } + catch (Exception ignore) { continue; } + + ForwardTarget ft = new ForwardTarget(id, type, name, imageUrl); + // کلید یکتا: id + type + map.put(id.toString() + "|" + type.toLowerCase(), ft); + } + } + + // (اختیاری) Saved Messages اگر داری می‌خوای اضافه کنی، اینجا اضافه کن. + + return new java.util.ArrayList<>(map.values()); + } + + + } diff --git a/src/main/java/org/to/telegramfinalproject/UI/ChatViewMode.java b/src/main/java/org/to/telegramfinalproject/UI/ChatViewMode.java new file mode 100644 index 0000000..459dbdd --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/UI/ChatViewMode.java @@ -0,0 +1,12 @@ +package org.to.telegramfinalproject.UI; + + +//For search handling +public enum ChatViewMode { + NORMAL, // member/contact; can send messages + NEEDS_JOIN, // group/channel preview; show Join button + NEEDS_ADD_CONTACT, // private preview; show Add Contact button + READ_ONLY, + BLOCKED + } + diff --git a/src/main/java/org/to/telegramfinalproject/UI/LoginController.java b/src/main/java/org/to/telegramfinalproject/UI/LoginController.java index c3a5124..2e16767 100644 --- a/src/main/java/org/to/telegramfinalproject/UI/LoginController.java +++ b/src/main/java/org/to/telegramfinalproject/UI/LoginController.java @@ -40,7 +40,7 @@ public class LoginController { visiblePasswordField.textProperty().bindBidirectional(passwordField.textProperty()); try { - connection = new ClientConnection("localhost", 8000); + connection = new ClientConnection("localhost", 8080); } catch (Exception e) { System.out.println("Could not connect to server: " + e.getMessage()); } diff --git a/src/main/java/org/to/telegramfinalproject/UI/MainController.java b/src/main/java/org/to/telegramfinalproject/UI/MainController.java index 95e3687..9cfbe52 100644 --- a/src/main/java/org/to/telegramfinalproject/UI/MainController.java +++ b/src/main/java/org/to/telegramfinalproject/UI/MainController.java @@ -40,6 +40,7 @@ public class MainController { GLOBAL, CHAT } + private ChatViewMode currentMode = ChatViewMode.NORMAL; // حالت فعلی: NORMAL/NEEDS_JOIN/NEEDS_ADD_CONTACT private SearchMode currentSearchMode = SearchMode.GLOBAL; private UUID currentChatId; // if in CHAT mode, which chat to search in @@ -429,6 +430,9 @@ public class MainController { FXMLLoader loader = new FXMLLoader(getClass().getResource("/org/to/telegramfinalproject/Fxml/chat_page.fxml")); Node chatPage = loader.load(); + + + ChatPageController controller = loader.getController(); controller.showChat(chat); @@ -445,6 +449,30 @@ public class MainController { } } + + private void openChatWithMode(ChatEntry chat, ChatViewMode mode) { + 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, mode); // ⬅️ متد جدید در ChatPageController + + 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(); + } + } + + @FXML private void toggleSidebar() { if (isSidebarOpen) { @@ -855,39 +883,52 @@ public class MainController { private void openSearchResult(SearchResult r) { switch (r.type) { case USER: { + // 1) اگه قبلاً چت پرایوت با این یوزر داری، از همون استفاده کن + UUID existingChatId = findExistingPrivateChatId(r.uuid); - java.util.UUID chatId = findExistingPrivateChatId(r.uuid); - if (chatId == null) { - chatId = fetchOrCreatePrivateChat(r.uuid); - if (chatId == null) { - System.out.println("❌ Failed to create/find private chat."); - return; - } + ChatEntry ce = new ChatEntry(); + ce.setType("private"); + ce.setName(r.title); + ce.setDisplayId(r.displayId); + try { ce.setOtherUserId(r.uuid); } catch (Exception ignore) {} + + ChatViewMode mode; + if (existingChatId != null) { + ce.setId(existingChatId.toString()); + mode = ChatViewMode.NORMAL; + } else { + // هنوز چتی وجود ندارد → Preview (بدون ساخت چت) + // برای Preview از uuid خودِ طرف مقابل به‌عنوان id موقت استفاده می‌کنیم + ce.setId(r.uuid.toString()); + mode = isContact(r.uuid) ? ChatViewMode.NORMAL : ChatViewMode.NEEDS_ADD_CONTACT; } - org.to.telegramfinalproject.Models.ChatEntry ce = new org.to.telegramfinalproject.Models.ChatEntry(); - ce.setId(chatId.toString()); // ⬅️ internal chat_id - ce.setDisplayId(r.displayId); // username - ce.setName(r.title); // profile_name - ce.setType("private"); - - openChat(ce); + openChatWithMode(ce, mode); break; } + case GROUP: case CHANNEL: { org.to.telegramfinalproject.Models.ChatEntry existing = findExistingChat(r.uuid, r.receiverType); if (existing != null) { - openChat(existing); + openChatWithMode(existing, ChatViewMode.NORMAL); } else { org.to.telegramfinalproject.Models.ChatEntry ce = new org.to.telegramfinalproject.Models.ChatEntry(); ce.setId(r.uuid.toString()); // internal_uuid group/channel ce.setDisplayId(r.displayId); // group_id/channel_id ce.setName(r.title); ce.setType(r.receiverType); - openChat(ce); + //openChat(ce); + ChatViewMode mode; + try { + mode = isInAnyChatList(r.uuid) ? ChatViewMode.NORMAL : ChatViewMode.NEEDS_JOIN; + } catch (Exception e) { + mode = ChatViewMode.NEEDS_JOIN; + } + openChatWithMode(ce, mode); + } break; } @@ -991,4 +1032,87 @@ public class MainController { return false; } + + + //Search + private boolean isInAnyChatList(UUID chatId) { + var lists = List.of( + Session.chatList != null ? Session.chatList : List.of(), + Session.activeChats != null ? Session.activeChats : List.of(), + Session.archivedChats != null ? Session.archivedChats : List.of() + ); + for (var lst : lists) { + for (var c : lst) { + try { + if (chatId.equals(UUID.fromString(c.getId().toString()))) return true; + } catch (Exception ignore) {} + } + } + return false; + } + + + private boolean isContact(UUID userUuid) { + if (Session.contactEntries == null) return false; + try { + for (var c : Session.contactEntries) { + UUID id = c.getContactId(); + if (userUuid.equals(id)) return true; + } + } catch (Exception ignore) {} + return false; + } + + + + private ChatViewMode computeMode(ChatEntry ce) { + try { + UUID id = UUID.fromString(ce.getId().toString()); + if (isInAnyChatList(id)) return ChatViewMode.NORMAL; + } catch (Exception ignore) {} + + String t = ce.getType(); + if ("group".equalsIgnoreCase(t) || "channel".equalsIgnoreCase(t)) { + return ChatViewMode.NEEDS_JOIN; + } + if ("private".equalsIgnoreCase(t)) { + UUID other = null; + try { other = ce.getOtherUserId(); } catch (Exception ignore) {} + return (other != null && isContact(other)) + ? ChatViewMode.NORMAL + : ChatViewMode.NEEDS_ADD_CONTACT; + } + return ChatViewMode.NORMAL; + } + + public void onJoinedOrAdded(ChatEntry ce) { + try { + UUID id = UUID.fromString(ce.getId().toString()); + if (!isInAnyChatList(id)) { + if (Session.chatList == null) Session.chatList = new ArrayList<>(); + Session.chatList.add(ce); + } + // اگر activeChats استفاده می‌کنی: + if (Session.activeChats != null && Session.activeChats.stream().noneMatch(c -> id.equals(c.getId()))) { + Session.activeChats.add(ce); + } + } catch (Exception ignore) {} + + // (اختیاری) مرتب‌سازی بر اساس زمان آخرین پیام + Comparator byTimeDesc = (a,b) -> { + LocalDateTime t1 = a.getLastMessageTime(), t2 = b.getLastMessageTime(); + if (t1 == null && t2 == null) return 0; + if (t1 == null) return 1; + if (t2 == null) return -1; + return t2.compareTo(t1); + }; + if (Session.chatList != null) Session.chatList.sort(byTimeDesc); + if (Session.activeChats != null) Session.activeChats.sort(byTimeDesc); + + refreshChatListUI(); + } + + + + } \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Utils/ChannelPermissionUtil.java b/src/main/java/org/to/telegramfinalproject/Utils/ChannelPermissionUtil.java index 709fee2..0b82779 100644 --- a/src/main/java/org/to/telegramfinalproject/Utils/ChannelPermissionUtil.java +++ b/src/main/java/org/to/telegramfinalproject/Utils/ChannelPermissionUtil.java @@ -73,4 +73,29 @@ public class ChannelPermissionUtil { } return false; } + + + + public static boolean isUserInChannel(UUID userId, UUID channelId) { + final String SQL = "SELECT 1 FROM channel_subscribers WHERE channel_id = ? AND user_id = ? LIMIT 1"; + try (Connection conn = ConnectionDb.connect(); + PreparedStatement ps = conn.prepareStatement(SQL)) { + + ps.setObject(1, channelId, java.sql.Types.OTHER); // 👈 مهم برای Postgres UUID + ps.setObject(2, userId, java.sql.Types.OTHER); + + System.out.println("[SQL] isUserInChannel ch=" + channelId + " user=" + userId + + " db=" + conn.getMetaData().getURL()); + + try (ResultSet rs = ps.executeQuery()) { + boolean ok = rs.next(); + System.out.println("[SQL] isUserInChannel -> " + ok); + return ok; + } + } catch (SQLException e) { + e.printStackTrace(); + return false; + } + } + } diff --git a/src/main/resources/init.sql b/src/main/resources/init.sql index e5b9720..5325bc0 100644 --- a/src/main/resources/init.sql +++ b/src/main/resources/init.sql @@ -156,5 +156,14 @@ CREATE TABLE IF NOT EXISTS message_attachments ( ); +--Run this part in your pg +ALTER TABLE message_attachments + ADD COLUMN IF NOT EXISTS media_key UUID; +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +UPDATE message_attachments +SET media_key = gen_random_uuid() +WHERE media_key IS NULL; + diff --git a/src/main/resources/org/to/telegramfinalproject/CSS/chat.css b/src/main/resources/org/to/telegramfinalproject/CSS/chat.css new file mode 100644 index 0000000..5def59d --- /dev/null +++ b/src/main/resources/org/to/telegramfinalproject/CSS/chat.css @@ -0,0 +1,76 @@ +/* دقیقاً فقط روی این دو دکمه اعمال می‌شود */ +#joinAction, #addContactAction { + -fx-background-color: transparent, transparent, transparent, transparent; + -fx-background-insets: 0,0,0,0; + -fx-background-radius: 0,0,0,0; + -fx-border-color: transparent; + -fx-border-width: 0; + -fx-text-fill: #1e88e5; /* آبی لینک */ + -fx-font-weight: 700; + -fx-font-size: 12px; + -fx-padding: 12 0 12 0; + -fx-cursor: hand; + -fx-effect: null; +} + +#joinAction:hover, #addContactAction:hover { + -fx-underline: true; +} + +#joinAction:focused, #addContactAction:focused { + -fx-underline: true; + -fx-focus-color: transparent; + -fx-faint-focus-color: transparent; +} + +#joinAction:armed, #addContactAction:armed { + -fx-opacity: .85; +} + +/* ظرف بنر پایین */ +.chat-footer-banner { + -fx-background-color: transparent; +} + +/* دکمهٔ لینک‌مانند (مشترک) */ +.footer-link-btn { + -fx-background-color: transparent; + -fx-background-insets: 0; + -fx-background-radius: 0; + -fx-padding: 6 0 6 0; /* نازک مثل لینک */ + -fx-border-color: transparent; + -fx-font-size: 14px; + -fx-font-weight: 700; + -fx-text-fill: #1a73e8; /* پیش‌فرض آبی (برای Join/Add) */ +} + +/* کانتینر بنر پایین چت */ +.chat-footer-banner { + -fx-background-color: transparent; + -fx-alignment: center; +} + +/* متن آبی برای حالت Read-only */ +.chat-footer-banner .banner-text-blue { + -fx-text-fill: #1E88E5; /* آبی */ + -fx-font-weight: 700; + -fx-background-color: transparent; +} + +/* لینک‌استایل دکمه‌ها در بنر */ +.chat-footer-banner .footer-link-btn { + -fx-background-color: transparent; + -fx-text-fill: #1E88E5; /* آبی پیش‌فرض */ + -fx-font-weight: 700; + -fx-padding: 6 12; + -fx-background-insets: 0; + -fx-cursor: hand; +} +.chat-footer-banner .footer-link-btn:hover { + -fx-underline: true; +} + +/* نسخه قرمز برای UNBLOCK */ +.chat-footer-banner .footer-link-btn.danger { + -fx-text-fill: #D32F2F; /* قرمز */ +} diff --git a/src/main/resources/org/to/telegramfinalproject/CSS/telegram-action.css b/src/main/resources/org/to/telegramfinalproject/CSS/telegram-action.css new file mode 100644 index 0000000..68a71e6 --- /dev/null +++ b/src/main/resources/org/to/telegramfinalproject/CSS/telegram-action.css @@ -0,0 +1,109 @@ +/* رنگ‌ها */ +:root { + -tg-bg: #ffffff; + -tg-sheet: #ffffff; + -tg-shadow: rgba(0,0,0,.25); + -tg-sep: rgba(0,0,0,.06); + -tg-text: #0f141a; + -tg-subtext: #6e7b87; + -tg-danger: #e53935; + -tg-primary: #2481cc; + -tg-chip-bg: #f5f7fa; +} + +/* ACTION SHEET */ +.tg-action-sheet { + -fx-background-color: -tg-sheet; + -fx-background-radius: 16; + -fx-effect: dropshadow(gaussian, -tg-shadow, 24, 0.26, 0, 4); + -fx-padding: 0; + -fx-border-radius: 16; +} + +.tg-reaction-bar { + -fx-background-color: transparent; + -fx-spacing: 12; +} +.tg-reaction { + -fx-font-size: 22px; + -fx-cursor: hand; + -fx-padding: 2 4 2 4; + -fx-background-radius: 12; +} +.tg-reaction:hover { + -fx-background-color: -tg-chip-bg; +} + +.tg-menu { -fx-background-color: transparent; } + +.tg-item { + -fx-background-color: transparent; + -fx-background-radius: 12; +} +.tg-item:hover { + -fx-background-color: -tg-chip-bg; +} +.tg-item .tg-label { + -fx-text-fill: -tg-text; + -fx-font-size: 16px; +} +.tg-item.danger .tg-label { + -fx-text-fill: -tg-danger; +} + +.tg-sep { + -fx-background-color: -tg-sep; + -fx-min-height: 1px; + -fx-pref-height: 1px; + -fx-max-height: 1px; + -fx-background-insets: 0 14 0 14; +} + +/* DELETE SHEET */ +.tg-delete-sheet { + -fx-background-color: -tg-bg; + -fx-background-radius: 16; + -fx-effect: dropshadow(gaussian, -tg-shadow, 28, 0.28, 0, 4); +} +.tg-delete-title { + -fx-font-size: 18px; + -fx-text-fill: -tg-text; +} +.tg-delete-check { + -fx-text-fill: -tg-text; + -fx-font-size: 14px; +} + +/* Buttons */ +.tg-btn-secondary { + -fx-background-color: transparent; + -fx-text-fill: -tg-primary; + -fx-font-size: 14px; + -fx-padding: 8 14 8 14; + -fx-background-radius: 10; +} +.tg-btn-secondary:hover { + -fx-background-color: -tg-chip-bg; +} + +.tg-btn-danger { + -fx-background-color: -tg-danger; + -fx-text-fill: white; + -fx-font-size: 14px; + -fx-padding: 8 16 8 16; + -fx-background-radius: 10; +} +.tg-btn-danger:hover { -fx-opacity: .9; } + +/* دارک‌مود (اگر CSS سوییچ داری، این کلس را به Scene اضافه کن) */ +.root.dark .tg-action-sheet, +.root.dark .tg-delete-sheet { -fx-background-color: #1f2a33; } +.root.dark { + -tg-bg: #1f2a33; + -tg-sheet: #22303a; + -tg-text: #e8f1f8; + -tg-subtext: #9bb2c3; + -tg-sep: rgba(255,255,255,.08); + -tg-chip-bg: rgba(255,255,255,.06); + -tg-shadow: rgba(0,0,0,.45); +} diff --git a/src/main/resources/org/to/telegramfinalproject/Fxml/chat_page.fxml b/src/main/resources/org/to/telegramfinalproject/Fxml/chat_page.fxml index e577564..2660490 100644 --- a/src/main/resources/org/to/telegramfinalproject/Fxml/chat_page.fxml +++ b/src/main/resources/org/to/telegramfinalproject/Fxml/chat_page.fxml @@ -6,17 +6,19 @@ + + + + - - - + @@ -33,9 +35,7 @@ @@ -44,9 +44,7 @@ + + +