1use serde::{Deserialize, Serialize};
4
5use crate::chat::{Chat, ChatType, ChatMetadata};
6use crate::compact::{encode_message_id, decode_message_id, NpubInterner};
7
8#[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 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 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 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
90pub fn get_all_chats() -> Result<Vec<SlimChatDB>, String> {
92 let conn = super::get_db_connection_guard_static()?;
93
94 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
133pub 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 "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
196pub fn delete_chat(chat_identifier: &str) -> Result<(), String> {
199 let conn = super::get_write_connection_guard_static()?;
200 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 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 #[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 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 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 #[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 #[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 #[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 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}