1use rusqlite::OptionalExtension;
5
6#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
7pub struct DesktopNotificationMessage {
8 pub chat_id: String,
9 pub message_id: String,
10}
11
12pub fn read_desktop_notification_indexes(
13 data_dir: String,
14 owner_pubkey_hex: String,
15 messages: Vec<DesktopNotificationMessage>,
16) -> Vec<u64> {
17 let Some(conn) = super::mobile_push::open_lookup_connection(&data_dir) else {
18 return Vec::new();
19 };
20 if super::storage::validate_account_storage(&conn, &owner_pubkey_hex).is_err() {
21 return Vec::new();
22 }
23 messages
24 .iter()
25 .enumerate()
26 .filter_map(|(index, message)| message_is_read(&conn, message).then_some(index as u64))
27 .collect()
28}
29
30fn message_is_read(conn: &rusqlite::Connection, message: &DesktopNotificationMessage) -> bool {
31 if message.chat_id.is_empty() || message.message_id.is_empty() {
32 return false;
33 }
34 let stored = conn
35 .query_row(
36 "SELECT EXISTS(SELECT 1 FROM messages WHERE chat_id = ?1 AND id = ?2
37 AND is_outgoing = 0 AND delivery = 'seen')",
38 rusqlite::params![message.chat_id, message.message_id],
39 |row| row.get::<_, bool>(0),
40 )
41 .unwrap_or(false);
42 if stored {
43 return true;
44 }
45 let pending = conn
48 .query_row(
49 "SELECT EXISTS(SELECT 1 FROM pending_message_reads WHERE chat_id = ?1 AND id = ?2)",
50 rusqlite::params![message.chat_id, message.message_id],
51 |row| row.get::<_, bool>(0),
52 )
53 .unwrap_or(false);
54 if pending {
55 return true;
56 }
57 let legacy: Option<String> = conn
58 .query_row(
59 "SELECT value FROM app_meta WHERE key = ?1",
60 [format!("chat_read_state:{}", message.chat_id)],
61 |row| row.get(0),
62 )
63 .optional()
64 .ok()
65 .flatten();
66 legacy
67 .and_then(|json| serde_json::from_str::<super::ChatReadState>(&json).ok())
68 .is_some_and(|state| state.exact_messages().contains_key(&message.message_id))
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74 const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
75
76 #[test]
77 fn exact_read_with_unread_siblings_survives_restart_and_stays_account_scoped() {
78 let directory = tempfile::tempdir().unwrap();
79 let conn = rusqlite::Connection::open(
80 directory
81 .path()
82 .join(super::super::storage::CORE_DB_FILENAME),
83 )
84 .unwrap();
85 conn.execute_batch("CREATE TABLE app_meta(key TEXT PRIMARY KEY, value TEXT);
86 CREATE TABLE messages(chat_id TEXT,id TEXT,is_outgoing INTEGER,delivery TEXT);
87 CREATE TABLE pending_message_reads(chat_id TEXT,id TEXT,created_at_secs INTEGER);
88 INSERT INTO messages VALUES('chat','seen',0,'seen'),('chat','older-unread',0,'received'),
89 ('other-chat','same-id',0,'seen'),('chat','outgoing',1,'seen');
90 INSERT INTO pending_message_reads VALUES('chat','arrives-later',50);").unwrap();
91 conn.execute(
92 "INSERT INTO app_meta VALUES('account_owner_pubkey_hex',?1)",
93 [OWNER],
94 )
95 .unwrap();
96 conn.execute("INSERT INTO app_meta VALUES('chat_read_state:legacy',?1)", [r#"{"updatedAtMs":1,"deviceId":"own-device","seenThroughSecs":99,"seenAtBoundary":["exact-boundary"],"seenMessages":{}}"#]).unwrap();
97 drop(conn);
98 let targets = [
99 ("chat", "seen"),
100 ("chat", "older-unread"),
101 ("chat", "same-id"),
102 ("chat", "outgoing"),
103 ("chat", "arrives-later"),
104 ("legacy", "exact-boundary"),
105 ("legacy", "older-unlisted"),
106 ]
107 .map(|(chat_id, message_id)| DesktopNotificationMessage {
108 chat_id: chat_id.into(),
109 message_id: message_id.into(),
110 })
111 .to_vec();
112 let path = directory.path().to_string_lossy().into_owned();
113 assert_eq!(
114 read_desktop_notification_indexes(path.clone(), OWNER.into(), targets.clone()),
115 vec![0, 4, 5]
116 );
117 let other_owner = nostr::Keys::generate().public_key().to_hex();
118 assert!(read_desktop_notification_indexes(path, other_owner, targets).is_empty());
119 }
120
121 #[test]
122 fn missing_store_is_not_read_proof() {
123 let directory = tempfile::tempdir().unwrap();
124 assert!(read_desktop_notification_indexes(
125 directory.path().to_string_lossy().into(),
126 OWNER.into(),
127 vec![DesktopNotificationMessage {
128 chat_id: "chat".into(),
129 message_id: "message".into()
130 }]
131 )
132 .is_empty());
133 }
134}