Skip to main content

vector_core/db/
chats.rs

1//! Chat database operations — CRUD for the chats table.
2
3use serde::{Deserialize, Serialize};
4
5use crate::chat::{Chat, ChatType, ChatMetadata};
6use crate::compact::{encode_message_id, decode_message_id, NpubInterner};
7
8/// Slim version of Chat for database storage.
9#[derive(Serialize, Deserialize, Clone, Debug)]
10pub struct SlimChatDB {
11    pub id: String,
12    pub chat_type: ChatType,
13    pub participants: Vec<String>,
14    pub last_read: String,
15    pub created_at: u64,
16    pub metadata: ChatMetadata,
17    pub muted: bool,
18    #[serde(default)]
19    pub wallpaper_path: String,
20    #[serde(default)]
21    pub wallpaper_ts: u64,
22    #[serde(default)]
23    pub wallpaper_blur: u8,
24    #[serde(default = "default_wallpaper_dim_slim")]
25    pub wallpaper_dim: u8,
26    #[serde(default)]
27    pub wallpaper_url: String,
28    #[serde(default)]
29    pub wallpaper_uploader: String,
30}
31
32fn default_wallpaper_dim_slim() -> u8 { 50 }
33
34impl SlimChatDB {
35    /// Create from a Chat, resolving interned handles to strings for DB storage.
36    pub fn from_chat(chat: &Chat, interner: &NpubInterner) -> Self {
37        SlimChatDB {
38            id: chat.id().clone(),
39            chat_type: chat.chat_type().clone(),
40            participants: chat.participants().iter()
41                .filter_map(|&h| interner.resolve(h).map(|s| s.to_string()))
42                .collect(),
43            last_read: if *chat.last_read() == [0u8; 32] {
44                String::new()
45            } else {
46                decode_message_id(chat.last_read())
47            },
48            created_at: chat.created_at(),
49            metadata: chat.metadata().clone(),
50            muted: chat.muted(),
51            wallpaper_path: chat.wallpaper_path.clone(),
52            wallpaper_ts: chat.wallpaper_ts,
53            wallpaper_blur: chat.wallpaper_blur,
54            wallpaper_dim: chat.wallpaper_dim,
55            wallpaper_url: chat.wallpaper_url.clone(),
56            wallpaper_uploader: chat.wallpaper_uploader.clone(),
57        }
58    }
59
60    /// Convert back to full Chat (messages loaded separately).
61    pub fn to_chat(&self, interner: &mut NpubInterner) -> Chat {
62        let mut handles: Vec<u16> = self.participants.iter().map(|p| interner.intern(p)).collect();
63        // Self-heal DM rows persisted as bare stubs (participants '[]'): the id IS
64        // the counterparty, and participant-keyed lookups need it present.
65        if handles.is_empty()
66            && matches!(self.chat_type, ChatType::DirectMessage)
67            && self.id.starts_with("npub1")
68        {
69            handles.push(interner.intern(&self.id));
70        }
71        let mut chat = Chat::new(self.id.clone(), self.chat_type.clone(), handles);
72        chat.last_read = if self.last_read.is_empty() {
73            [0u8; 32]
74        } else {
75            encode_message_id(&self.last_read)
76        };
77        chat.created_at = self.created_at;
78        chat.metadata = self.metadata.clone();
79        chat.muted = self.muted;
80        chat.wallpaper_path = self.wallpaper_path.clone();
81        chat.wallpaper_ts = self.wallpaper_ts;
82        chat.wallpaper_blur = self.wallpaper_blur;
83        chat.wallpaper_dim = self.wallpaper_dim;
84        chat.wallpaper_url = self.wallpaper_url.clone();
85        chat.wallpaper_uploader = self.wallpaper_uploader.clone();
86        chat
87    }
88}
89
90/// Get all chats from the database.
91pub fn get_all_chats() -> Result<Vec<SlimChatDB>, String> {
92    let conn = super::get_db_connection_guard_static()?;
93
94    // chat_type 1 was the removed MLS group variant — legacy rows are dropped at load.
95    let mut stmt = conn.prepare(
96        "SELECT chat_identifier, chat_type, participants, last_read, created_at, metadata, muted, \
97                wallpaper_path, wallpaper_ts, wallpaper_blur, wallpaper_dim, \
98                wallpaper_url, wallpaper_uploader \
99         FROM chats WHERE chat_type != 1 ORDER BY created_at DESC"
100    ).map_err(|e| format!("Failed to prepare statement: {}", e))?;
101
102    let rows = stmt.query_map([], |row| {
103        let participants_json: String = row.get(2)?;
104        let participants: Vec<String> = serde_json::from_str(&participants_json).unwrap_or_default();
105
106        let metadata_json: String = row.get(5)?;
107        let metadata: ChatMetadata = serde_json::from_str(&metadata_json).unwrap_or_default();
108
109        let chat_type_int: i32 = row.get(1)?;
110        let chat_type = ChatType::from_i32(chat_type_int);
111
112        Ok(SlimChatDB {
113            id: row.get(0)?,
114            chat_type,
115            participants,
116            last_read: row.get(3)?,
117            created_at: row.get::<_, i64>(4)? as u64,
118            metadata,
119            muted: row.get::<_, i32>(6)? != 0,
120            wallpaper_path: row.get(7)?,
121            wallpaper_ts: row.get::<_, i64>(8)? as u64,
122            wallpaper_blur: row.get::<_, i32>(9)?.clamp(0, 30) as u8,
123            wallpaper_dim: row.get::<_, i32>(10)?.clamp(0, 100) as u8,
124            wallpaper_url: row.get(11)?,
125            wallpaper_uploader: row.get(12)?,
126        })
127    }).map_err(|e| format!("Failed to query chats: {}", e))?;
128
129    rows.collect::<Result<Vec<_>, _>>()
130        .map_err(|e| format!("Failed to collect chats: {}", e))
131}
132
133/// Upsert a chat to the database.
134/// Clear a chat's read marker to never-read. The [`save_slim_chat`] upsert
135/// deliberately refuses to regress `last_read` to empty (a partially-hydrated
136/// STATE chat would otherwise wipe the stored position), so a DELIBERATE
137/// clear — Mark as Unread on a chat whose only message is the target — must
138/// come through here or it silently no-ops.
139pub fn clear_chat_last_read(chat_identifier: &str) -> Result<(), String> {
140    let conn = super::get_write_connection_guard_static()?;
141    conn.execute(
142        "UPDATE chats SET last_read = '' WHERE chat_identifier = ?1",
143        rusqlite::params![chat_identifier],
144    )
145    .map_err(|e| format!("clear_chat_last_read: {e}"))?;
146    Ok(())
147}
148
149pub fn save_slim_chat(slim_chat: &SlimChatDB) -> Result<(), String> {
150    if slim_chat.id.is_empty() {
151        return Err("refusing to persist a chat with an empty identifier".into());
152    }
153    let conn = super::get_write_connection_guard_static()?;
154
155    let chat_type_int = slim_chat.chat_type.to_i32();
156    let participants_json = serde_json::to_string(&slim_chat.participants)
157        .unwrap_or_else(|_| "[]".to_string());
158    let metadata_json = serde_json::to_string(&slim_chat.metadata)
159        .unwrap_or_else(|_| "{}".to_string());
160
161    conn.execute(
162        // `last_read` never regresses to empty through a chat save: a STATE chat can
163        // predate marker hydration (realtime-created, partial boot), and persisting its
164        // empty marker would wipe the stored read position — resurrecting every message
165        // since as phantom unread. Marker clears go through the dedicated
166        // `UPDATE chats SET last_read` paths, not this upsert.
167        "INSERT INTO chats (chat_identifier, chat_type, participants, last_read, created_at, metadata, muted, wallpaper_path, wallpaper_ts, wallpaper_blur, wallpaper_dim, wallpaper_url, wallpaper_uploader) \
168         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
169         ON CONFLICT(chat_identifier) DO UPDATE SET \
170            chat_type = excluded.chat_type, participants = excluded.participants, \
171            last_read = CASE WHEN excluded.last_read = '' THEN chats.last_read ELSE excluded.last_read END, \
172            metadata = excluded.metadata, muted = excluded.muted, \
173            wallpaper_path = excluded.wallpaper_path, wallpaper_ts = excluded.wallpaper_ts, \
174            wallpaper_blur = excluded.wallpaper_blur, wallpaper_dim = excluded.wallpaper_dim, \
175            wallpaper_url = excluded.wallpaper_url, wallpaper_uploader = excluded.wallpaper_uploader",
176        rusqlite::params![
177            slim_chat.id,
178            chat_type_int,
179            participants_json,
180            slim_chat.last_read,
181            slim_chat.created_at as i64,
182            metadata_json,
183            slim_chat.muted as i32,
184            slim_chat.wallpaper_path,
185            slim_chat.wallpaper_ts as i64,
186            slim_chat.wallpaper_blur as i32,
187            slim_chat.wallpaper_dim as i32,
188            slim_chat.wallpaper_url,
189            slim_chat.wallpaper_uploader,
190        ],
191    ).map_err(|e| format!("Failed to upsert chat: {}", e))?;
192
193    Ok(())
194}
195
196/// Delete a chat and all its messages from the database. `chat_identifier` is the
197/// string id (npub for DMs, channel id for Communities) — NOT the integer PK.
198pub fn delete_chat(chat_identifier: &str) -> Result<(), String> {
199    let conn = super::get_write_connection_guard_static()?;
200    // Drop messages first (explicit, not reliant on the FK cascade pragma being on).
201    conn.execute(
202        "DELETE FROM events WHERE chat_id IN (SELECT id FROM chats WHERE chat_identifier = ?1)",
203        rusqlite::params![chat_identifier],
204    ).map_err(|e| format!("Failed to delete chat events: {}", e))?;
205    conn.execute(
206        "DELETE FROM chats WHERE chat_identifier = ?1",
207        rusqlite::params![chat_identifier],
208    ).map_err(|e| format!("Failed to delete chat: {}", e))?;
209    super::id_cache::forget_chat_id(chat_identifier);
210    Ok(())
211}
212
213#[cfg(test)]
214mod tests {
215    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(900);
216
217    fn make_test_npub(n: u32) -> String {
218        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
219        let mut payload = vec![b'q'; 58];
220        let mut x = n as u64;
221        let mut i = 58;
222        while x > 0 && i > 0 {
223            i -= 1;
224            payload[i] = BECH32[(x as usize) % 32];
225            x /= 32;
226        }
227        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
228    }
229
230    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
231        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
232        crate::db::close_database();
233        // Per-account row-id caches survive close_database; clear them so a stale entry from a prior
234        // test's DB can't point into this fresh account's DB and FK-fail an insert.
235        crate::db::clear_id_caches();
236        let tmp = tempfile::tempdir().unwrap();
237        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
238        let account = make_test_npub(n);
239        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
240        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
241        crate::db::set_current_account(account.clone()).unwrap();
242        crate::db::init_database(&account).unwrap();
243        (tmp, guard)
244    }
245
246    // A chat save carrying an EMPTY marker (a STATE chat that predates marker
247    // hydration) must not wipe the persisted read position — message persists
248    // re-save the chat row constantly, and a wipe resurrects every message
249    // since as phantom unread. A real marker still advances normally.
250    #[test]
251    fn chat_upsert_preserves_last_read_against_empty_marker() {
252        let (_tmp, _guard) = init_test_db();
253        let chat_id = "npub1markerkeeper";
254
255        let mut slim = super::SlimChatDB {
256            id: chat_id.to_string(),
257            chat_type: crate::ChatType::DirectMessage,
258            participants: vec![],
259            last_read: "aa".repeat(32),
260            created_at: 1000,
261            metadata: crate::chat::ChatMetadata::default(),
262            muted: false,
263            wallpaper_path: String::new(),
264            wallpaper_ts: 0,
265            wallpaper_blur: 0,
266            wallpaper_dim: 50,
267            wallpaper_url: String::new(),
268            wallpaper_uploader: String::new(),
269        };
270        super::save_slim_chat(&slim).unwrap();
271
272        // Un-hydrated STATE copy re-saves the row: marker survives.
273        slim.last_read = String::new();
274        super::save_slim_chat(&slim).unwrap();
275        let chats = super::get_all_chats().unwrap();
276        let chat = chats.iter().find(|c| c.id == chat_id).expect("chat saved");
277        assert_eq!(chat.last_read, "aa".repeat(32), "empty marker must not wipe the stored one");
278
279        // A real marker still advances.
280        slim.last_read = "bb".repeat(32);
281        super::save_slim_chat(&slim).unwrap();
282        let chats = super::get_all_chats().unwrap();
283        let chat = chats.iter().find(|c| c.id == chat_id).expect("chat saved");
284        assert_eq!(chat.last_read, "bb".repeat(32), "non-empty marker advances normally");
285    }
286
287    // Regression: a non-npub id stub-created via get_or_create_chat_id must use the
288    // Community discriminant (2), not the retired MLS value (1) which get_all_chats
289    // drops — otherwise the chat (and its messages) vanish on the next reload.
290    #[test]
291    fn stub_created_non_npub_chat_survives_reload() {
292        let (_tmp, _guard) = init_test_db();
293        let channel_id = "abc123def456channelid";
294        let _ = crate::db::id_cache::get_or_create_chat_id(channel_id).unwrap();
295
296        let chats = super::get_all_chats().unwrap();
297        let found = chats.iter().find(|c| c.id == channel_id)
298            .expect("stub-created non-npub chat must survive get_all_chats");
299        assert_eq!(found.chat_type, crate::ChatType::Community);
300    }
301
302    // Regression: a DM row stub-created by a message save (no prior chat row) must carry
303    // its counterparty — a bare '[]' roster boots into STATE participant-less, and every
304    // participant-keyed lookup (attachment downloads) misses forever after.
305    #[test]
306    fn stub_created_dm_chat_carries_its_counterparty() {
307        let (_tmp, _guard) = init_test_db();
308        let npub = "npub1stubcounterparty";
309        let _ = crate::db::id_cache::get_or_create_chat_id(npub).unwrap();
310
311        let chats = super::get_all_chats().unwrap();
312        let found = chats.iter().find(|c| c.id == npub).expect("stub DM row exists");
313        assert_eq!(found.chat_type, crate::ChatType::DirectMessage);
314        assert_eq!(found.participants, vec![npub.to_string()], "the DM's id IS its participant");
315    }
316
317    // Rows already persisted with an empty roster (pre-fix stubs) heal at load: the id
318    // is the counterparty, so to_chat re-derives it.
319    #[test]
320    fn to_chat_heals_bare_dm_participants() {
321        let slim = super::SlimChatDB {
322            id: "npub1baredmrow".to_string(),
323            chat_type: crate::ChatType::DirectMessage,
324            participants: vec![],
325            last_read: String::new(),
326            created_at: 1000,
327            metadata: crate::chat::ChatMetadata::default(),
328            muted: false,
329            wallpaper_path: String::new(),
330            wallpaper_ts: 0,
331            wallpaper_blur: 0,
332            wallpaper_dim: 50,
333            wallpaper_url: String::new(),
334            wallpaper_uploader: String::new(),
335        };
336        let mut interner = crate::compact::NpubInterner::new();
337        let chat = slim.to_chat(&mut interner);
338        assert!(chat.has_participant("npub1baredmrow", &interner), "bare DM roster heals from the id");
339
340        // A community row with no participants stays empty — only DMs derive from the id.
341        let mut community = slim.clone();
342        community.id = "aabbccddeeff00".to_string();
343        community.chat_type = crate::ChatType::Community;
344        let chat = community.to_chat(&mut interner);
345        assert!(chat.participants().is_empty(), "non-DM rosters are not invented");
346    }
347}