Message receipts

This commit is contained in:
2025-08-29 21:52:27 +03:30
parent 5a2f4c6de7
commit 2bae5a5b78
3 changed files with 374 additions and 107 deletions
@@ -381,71 +381,180 @@ public class MessageDatabase {
// public static List<Message> getUnreadMessages(UUID userId) {
// List<Message> messages = new ArrayList<>();
//
// String sql = """
// SELECT m.*
// FROM messages m
// LEFT JOIN message_receipts r ON m.message_id = r.message_id AND r.user_id = ?
// LEFT JOIN deleted_messages d ON m.message_id = d.message_id AND d.user_id = ?
// WHERE r.user_id IS NULL
// AND d.message_id IS NULL
// AND m.is_deleted_globally = FALSE
// AND (
// (m.receiver_type = 'private' AND m.receiver_id = ?)
// OR
// (m.receiver_type = 'group' AND EXISTS (
// SELECT 1 FROM group_members gm WHERE gm.group_id = m.receiver_id AND gm.user_id = ?
// ))
// OR
// (m.receiver_type = 'channel' AND EXISTS (
// SELECT 1 FROM channel_subscribers cs WHERE cs.channel_id = m.receiver_id AND cs.user_id = ?
// ))
// )
// ORDER BY m.send_at DESC
// """;
//
// try (Connection conn = ConnectionDb.connect();
// PreparedStatement stmt = conn.prepareStatement(sql)) {
//
// stmt.setObject(1, userId); // for message_receipts
// stmt.setObject(2, userId); // for deleted_messages
// stmt.setObject(3, userId); // for private messages
// stmt.setObject(4, userId); // for group members
// stmt.setObject(5, userId); // for channel subscribers
//
// ResultSet rs = stmt.executeQuery();
// while (rs.next()) {
// Message message = new Message(
// UUID.fromString(rs.getString("message_id")),
// rs.getObject("sender_id") != null ? UUID.fromString(rs.getString("sender_id")) : null,
// rs.getString("receiver_type"),
// UUID.fromString(rs.getString("receiver_id")),
// rs.getString("content"),
// rs.getString("message_type"),
// rs.getTimestamp("send_at").toLocalDateTime(),
// rs.getString("status"),
// rs.getObject("reply_to_id") != null ? UUID.fromString(rs.getString("reply_to_id")) : null,
// rs.getBoolean("is_edited"),
// rs.getObject("original_message_id") != null ? UUID.fromString(rs.getString("original_message_id")) : null,
// rs.getObject("forwarded_by") != null ? UUID.fromString(rs.getString("forwarded_by")) : null,
// rs.getObject("forwarded_from") != null ? UUID.fromString(rs.getString("forwarded_from")) : null,
// rs.getBoolean("is_deleted_globally"),
// rs.getTimestamp("edited_at") != null ? rs.getTimestamp("edited_at").toLocalDateTime() : null
// );
//
// messages.add(message);
// }
//
// } catch (SQLException e) {
// e.printStackTrace();
// }
//
// return messages;
// }
public static List<Message> getUnreadMessages(UUID userId) { public static List<Message> getUnreadMessages(UUID userId) {
return getUnreadMessages(userId, 200); // یک سقف معقول
}
public static List<Message> getUnreadMessages(UUID userId, int limit) {
List<Message> messages = new ArrayList<>(); List<Message> messages = new ArrayList<>();
// توجه: نام جدول private_chats/gm/cs را با اسامی واقعی دیتابیس‌ت هماهنگ کن
String sql = """ String sql = """
SELECT m.* SELECT m.*
FROM messages m FROM messages m
LEFT JOIN message_receipts r ON m.message_id = r.message_id AND r.user_id = ? LEFT JOIN message_receipts r
LEFT JOIN deleted_messages d ON m.message_id = d.message_id AND d.user_id = ? ON r.message_id = m.message_id
WHERE r.user_id IS NULL AND r.user_id = ?
LEFT JOIN deleted_messages d
ON d.message_id = m.message_id
AND d.user_id = ?
WHERE r.message_id IS NULL
AND d.message_id IS NULL AND d.message_id IS NULL
AND m.is_deleted_globally = FALSE AND m.is_deleted_globally = FALSE
AND m.sender_id <> ?
AND ( AND (
(m.receiver_type = 'private' AND m.receiver_id = ?) (m.receiver_type = 'private' AND EXISTS (
OR SELECT 1
(m.receiver_type = 'group' AND EXISTS ( FROM private_chat pc
SELECT 1 FROM group_members gm WHERE gm.group_id = m.receiver_id AND gm.user_id = ? WHERE pc.chat_id = m.receiver_id
)) AND (pc.user1_id = ? OR pc.user2_id = ?)
OR )))
(m.receiver_type = 'channel' AND EXISTS ( OR (m.receiver_type = 'group' AND EXISTS (
SELECT 1 FROM channel_subscribers cs WHERE cs.channel_id = m.receiver_id AND cs.user_id = ? SELECT 1
)) FROM group_members gm
WHERE gm.group_id = m.receiver_id
AND gm.user_id = ?
))
OR (m.receiver_type = 'channel' AND EXISTS (
SELECT 1
FROM channel_subscribers cs
WHERE cs.channel_id = m.receiver_id
AND cs.user_id = ?
))
) )
ORDER BY m.send_at DESC ORDER BY m.send_at ASC
"""; LIMIT ?
""";
try (Connection conn = ConnectionDb.connect(); try (Connection conn = ConnectionDb.connect();
PreparedStatement stmt = conn.prepareStatement(sql)) { PreparedStatement ps = conn.prepareStatement(sql)) {
stmt.setObject(1, userId); // for message_receipts int i = 1;
stmt.setObject(2, userId); // for deleted_messages ps.setObject(i++, userId); // r.user_id
stmt.setObject(3, userId); // for private messages ps.setObject(i++, userId); // d.user_id
stmt.setObject(4, userId); // for group members ps.setObject(i++, userId); // m.sender_id <> ?
stmt.setObject(5, userId); // for channel subscribers ps.setObject(i++, userId); // pc.user1_id=userId
ps.setObject(i++, userId); // pc.user2_id=userId
ps.setObject(i++, userId); // gm.user_id
ps.setObject(i++, userId); // cs.user_id
ps.setInt(i++, Math.max(1, limit));
ResultSet rs = stmt.executeQuery(); try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { while (rs.next()) {
Message message = new Message( messages.add(mapMessage(rs)); // همان mapMessage که گفتیم
UUID.fromString(rs.getString("message_id")), }
rs.getObject("sender_id") != null ? UUID.fromString(rs.getString("sender_id")) : null,
rs.getString("receiver_type"),
UUID.fromString(rs.getString("receiver_id")),
rs.getString("content"),
rs.getString("message_type"),
rs.getTimestamp("send_at").toLocalDateTime(),
rs.getString("status"),
rs.getObject("reply_to_id") != null ? UUID.fromString(rs.getString("reply_to_id")) : null,
rs.getBoolean("is_edited"),
rs.getObject("original_message_id") != null ? UUID.fromString(rs.getString("original_message_id")) : null,
rs.getObject("forwarded_by") != null ? UUID.fromString(rs.getString("forwarded_by")) : null,
rs.getObject("forwarded_from") != null ? UUID.fromString(rs.getString("forwarded_from")) : null,
rs.getBoolean("is_deleted_globally"),
rs.getTimestamp("edited_at") != null ? rs.getTimestamp("edited_at").toLocalDateTime() : null
);
messages.add(message);
} }
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); e.printStackTrace();
} }
return messages; return messages;
} }
private static java.util.UUID readUUID(ResultSet rs, String col) throws SQLException {
Object o = rs.getObject(col);
if (o == null) return null;
if (o instanceof java.util.UUID) return (java.util.UUID) o;
return java.util.UUID.fromString(o.toString());
}
private static java.time.LocalDateTime readLdt(ResultSet rs, String col) throws SQLException {
java.sql.Timestamp ts = rs.getTimestamp(col);
return (ts == null) ? null : ts.toLocalDateTime();
}
private static boolean readBool(ResultSet rs, String col) throws SQLException {
boolean v = rs.getBoolean(col);
return v;
}
private static Message mapMessage(ResultSet rs) throws SQLException {
Message m = new Message();
m.setMessage_id( readUUID(rs, "message_id") );
m.setSender_id( readUUID(rs, "sender_id") );
m.setReceiver_type( rs.getString("receiver_type") );
m.setReceiver_id( readUUID(rs, "receiver_id") );
m.setContent( rs.getString("content") );
m.setMessage_type( rs.getString("message_type") );
m.setSend_at( readLdt(rs, "send_at") );
m.setStatus( rs.getString("status") );
m.setReply_to_id( readUUID(rs, "reply_to_id") );
m.setIs_edited( readBool(rs, "is_edited") );
m.setEdited_at( readLdt(rs, "edited_at") );
m.setOriginal_message_id( readUUID(rs, "original_message_id") );
m.setForwarded_by( readUUID(rs, "forwarded_by") );
m.setForwarded_from( readUUID(rs, "forwarded_from") );
m.setIs_deleted_globally( readBool(rs, "is_deleted_globally") );
return m;
}
public static List<FileAttachment> getAttachments(UUID messageId) { public static List<FileAttachment> getAttachments(UUID messageId) {
List<FileAttachment> attachments = new ArrayList<>(); List<FileAttachment> attachments = new ArrayList<>();
@@ -1079,30 +1188,30 @@ public class MessageDatabase {
} }
} }
public static int getUnreadCount(UUID me, UUID targetId, String type) { // public static int getUnreadCount(UUID me, UUID targetId, String type) {
final String sql = // final String sql =
"SELECT COUNT(*) FROM messages " + // "SELECT COUNT(*) FROM messages " +
"WHERE receiver_type = ? AND receiver_id = ? " + // "WHERE receiver_type = ? AND receiver_id = ? " +
"AND sender_id <> ? " + // "AND sender_id <> ? " +
"AND (status IS NULL OR status <> 'SEEN')"; // "AND (status IS NULL OR status <> 'SEEN')";
//
try (Connection conn = ConnectionDb.connect(); // try (Connection conn = ConnectionDb.connect();
PreparedStatement ps = conn.prepareStatement(sql)) { // PreparedStatement ps = conn.prepareStatement(sql)) {
//
ps.setString(1, type.toLowerCase()); // ps.setString(1, type.toLowerCase());
ps.setObject(2, targetId); // ps.setObject(2, targetId);
ps.setObject(3, me); // ps.setObject(3, me);
//
try (ResultSet rs = ps.executeQuery()) { // try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt(1) : 0; // return rs.next() ? rs.getInt(1) : 0;
} // }
} catch (SQLException e) { // } catch (SQLException e) {
e.printStackTrace(); // e.printStackTrace();
return 0; // return 0;
} // }
//
//
} // }
private static Message mapRow(ResultSet rs) throws SQLException { private static Message mapRow(ResultSet rs) throws SQLException {
Message m = new Message(); Message m = new Message();
@@ -1126,4 +1235,112 @@ public class MessageDatabase {
return m; return m;
} }
public static List<UUID> getUnreadMessageIds(UUID me, UUID chatId, String chatType, int limit) {
String sql = """
SELECT m.message_id
FROM messages m
WHERE m.receiver_id = ? AND m.receiver_type = ?
AND m.sender_id <> ?
AND NOT EXISTS (
SELECT 1 FROM message_receipts r
WHERE r.message_id = m.message_id
AND r.user_id = ?
)
ORDER BY m.send_at ASC
LIMIT ?
""";
List<UUID> ids = new ArrayList<>();
try (Connection conn = ConnectionDb.connect();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setObject(1, chatId);
ps.setString(2, chatType);
ps.setObject(3, me);
ps.setObject(4, me);
ps.setInt(5, limit);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
ids.add((UUID) rs.getObject(1));
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return ids;
}
// تعداد ناخوانده‌ها (برای چت‌لیست)
public static int getUnreadCount(UUID me, UUID chatId, String chatType) {
String sql = """
SELECT COUNT(1)
FROM messages m
WHERE m.receiver_id = ?
AND m.receiver_type = ?
AND m.sender_id <> ?
AND NOT EXISTS (
SELECT 1 FROM message_receipts r
WHERE r.message_id = m.message_id
AND r.user_id = ?
)
""";
try (Connection conn = ConnectionDb.connect();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setObject(1, chatId);
ps.setString(2, chatType);
ps.setObject(3, me);
ps.setObject(4, me);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt(1) : 0;
}
} catch (SQLException e) {
e.printStackTrace();
return 0;
}
}
public static int insertReceiptIfAbsent(UUID messageId, UUID userId) {
String sql = """
INSERT INTO message_receipts(message_id, user_id, read_at)
VALUES (?, ?, now())
ON CONFLICT (message_id, user_id) DO NOTHING
""";
try (Connection conn = ConnectionDb.connect();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setObject(1, messageId);
ps.setObject(2, userId);
return ps.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
return 0;
}
}
//Read status if it's first reader
public static int setMessageReadIfNeeded(UUID messageId, UUID viewerId) {
String sql = """
UPDATE messages m
SET status = 'READ'
WHERE m.message_id = ?
AND m.sender_id <> ?
AND m.status <> 'READ'
""";
try (Connection conn = ConnectionDb.connect();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setObject(1, messageId);
ps.setObject(2, viewerId);
return ps.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
return 0;
}
}
} }
@@ -11,6 +11,7 @@ import org.to.telegramfinalproject.Utils.GroupPermissionUtil;
import java.io.*; import java.io.*;
import java.net.Socket; import java.net.Socket;
import java.sql.Connection;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
@@ -2734,6 +2735,55 @@ public class ClientHandler implements Runnable {
break; break;
} }
case "mark_as_read": {
if (currentUser == null) {
response = new ResponseModel("error", "Unauthorized. Please login first.");
break;
}
try {
UUID chatId = UUID.fromString(requestJson.getString("receiver_id"));
String chatType = requestJson.getString("receiver_type").toLowerCase();
int limit = requestJson.optInt("limit", 500);
List<UUID> targetMessageIds = new ArrayList<>();
if (requestJson.has("message_ids")) {
JSONArray arr = requestJson.getJSONArray("message_ids");
for (int i = 0; i < arr.length(); i++) {
targetMessageIds.add(UUID.fromString(arr.getString(i)));
}
} else {
targetMessageIds = MessageDatabase.getUnreadMessageIds(
currentUser.getInternal_uuid(), chatId, chatType, limit
);
}
int updatedStatus = 0;
int insertedReceipts = 0;
try (Connection c = ConnectionDb.connect()) {
c.setAutoCommit(false);
for (UUID mid : targetMessageIds) {
updatedStatus += MessageDatabase.setMessageReadIfNeeded(mid, currentUser.getInternal_uuid());
insertedReceipts+= MessageDatabase.insertReceiptIfAbsent(mid, currentUser.getInternal_uuid());
}
c.commit();
} catch (Exception tx) {
tx.printStackTrace();
}
JSONObject data = new JSONObject();
data.put("marked_count", targetMessageIds.size());
data.put("status_updates", updatedStatus);
data.put("receipts_inserted", insertedReceipts);
response = new ResponseModel("success", "Marked as read.", data);
} catch (Exception e) {
response = new ResponseModel("error", "Error in mark_as_read: " + e.getMessage());
}
break;
}
default: default:
response = new ResponseModel("error", "Unknown action: " + action); response = new ResponseModel("error", "Unknown action: " + action);
} }
@@ -109,9 +109,9 @@ public class ChatPageController {
//Time formatter for messages //Time formatter for messages
private static final DateTimeFormatter FMT_HHMM = DateTimeFormatter.ofPattern("HH:mm"); // private static final DateTimeFormatter FMT_HHMM = DateTimeFormatter.ofPattern("HH:mm");
private static final DateTimeFormatter FMT_DATE_TIME = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm"); // private static final DateTimeFormatter FMT_DATE_TIME = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm");
private static final String YESTERDAY_LABEL = "Yesterday"; // private static final String YESTERDAY_LABEL = "Yesterday";
private static String str(org.json.JSONObject j, String k) { private static String str(org.json.JSONObject j, String k) {
try { return (j.has(k) && !j.isNull(k)) ? j.getString(k) : ""; } catch (Exception e) { return ""; } try { return (j.has(k) && !j.isNull(k)) ? j.getString(k) : ""; } catch (Exception e) { return ""; }
@@ -261,43 +261,43 @@ public class ChatPageController {
themeManager.darkModeProperty().addListener((o, oldVal, isDark) -> syncIconsWithTheme()); themeManager.darkModeProperty().addListener((o, oldVal, isDark) -> syncIconsWithTheme());
} }
private void initCurrentUserId() { // private void initCurrentUserId() {
try { // try {
String meStr = org.to.telegramfinalproject.Client.Session // String meStr = org.to.telegramfinalproject.Client.Session
.currentUser.getString("internal_uuid"); // .currentUser.getString("internal_uuid");
me = UUID.fromString(meStr); // me = UUID.fromString(meStr);
} catch (Exception ignore) { // } catch (Exception ignore) {
me = null; // me = null;
} // }
} // }
//
private String formatWhen(LocalDateTime ts) { // private String formatWhen(LocalDateTime ts) {
if (ts == null) return ""; // if (ts == null) return "";
LocalDate today = LocalDate.now(); // LocalDate today = LocalDate.now();
LocalDate d = ts.toLocalDate(); // LocalDate d = ts.toLocalDate();
//
if (d.isEqual(today)) { // if (d.isEqual(today)) {
return FMT_HHMM.format(ts); // return FMT_HHMM.format(ts);
} else if (d.isEqual(today.minusDays(1))) { // } else if (d.isEqual(today.minusDays(1))) {
return YESTERDAY_LABEL + " " + FMT_HHMM.format(ts); // return YESTERDAY_LABEL + " " + FMT_HHMM.format(ts);
} else { // } else {
return FMT_DATE_TIME.format(ts); // return FMT_DATE_TIME.format(ts);
} // }
} // }
//
/** ISO → LocalDateTime (با پشتیبانی از Offset/Z) */ // /** ISO → LocalDateTime (با پشتیبانی از Offset/Z) */
private LocalDateTime parseWhen(String iso) { // private LocalDateTime parseWhen(String iso) {
if (iso == null || iso.isEmpty()) return null; // if (iso == null || iso.isEmpty()) return null;
try { // try {
return OffsetDateTime.parse(iso).toLocalDateTime(); // return OffsetDateTime.parse(iso).toLocalDateTime();
} catch (Exception ignore) { // } catch (Exception ignore) {
try { // try {
return LocalDateTime.parse(iso); // return LocalDateTime.parse(iso);
} catch (Exception e) { // } catch (Exception e) {
return null; // return null;
} // }
} // }
} // }
@FXML @FXML
private void openSearchPanel() { private void openSearchPanel() {