Skip to main content

iris_chat_core/core/
desktop_read_notifications.rs

1//! Match desktop notification metadata against durable private read state.
2//! No timestamp cutoff or unread-count change proves another message was seen.
3
4use 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    // A linked-device read can arrive before its matching message. Event IDs
46    // bind message contents/timestamps; only an exact chat and ID match counts.
47    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}