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 let conn = super::get_write_connection_guard_static()?;
151
152 let chat_type_int = slim_chat.chat_type.to_i32();
153 let participants_json = serde_json::to_string(&slim_chat.participants)
154 .unwrap_or_else(|_| "[]".to_string());
155 let metadata_json = serde_json::to_string(&slim_chat.metadata)
156 .unwrap_or_else(|_| "{}".to_string());
157
158 conn.execute(
159 "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) \
165 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
166 ON CONFLICT(chat_identifier) DO UPDATE SET \
167 chat_type = excluded.chat_type, participants = excluded.participants, \
168 last_read = CASE WHEN excluded.last_read = '' THEN chats.last_read ELSE excluded.last_read END, \
169 metadata = excluded.metadata, muted = excluded.muted, \
170 wallpaper_path = excluded.wallpaper_path, wallpaper_ts = excluded.wallpaper_ts, \
171 wallpaper_blur = excluded.wallpaper_blur, wallpaper_dim = excluded.wallpaper_dim, \
172 wallpaper_url = excluded.wallpaper_url, wallpaper_uploader = excluded.wallpaper_uploader",
173 rusqlite::params![
174 slim_chat.id,
175 chat_type_int,
176 participants_json,
177 slim_chat.last_read,
178 slim_chat.created_at as i64,
179 metadata_json,
180 slim_chat.muted as i32,
181 slim_chat.wallpaper_path,
182 slim_chat.wallpaper_ts as i64,
183 slim_chat.wallpaper_blur as i32,
184 slim_chat.wallpaper_dim as i32,
185 slim_chat.wallpaper_url,
186 slim_chat.wallpaper_uploader,
187 ],
188 ).map_err(|e| format!("Failed to upsert chat: {}", e))?;
189
190 Ok(())
191}
192
193pub fn delete_chat(chat_identifier: &str) -> Result<(), String> {
196 let conn = super::get_write_connection_guard_static()?;
197 conn.execute(
199 "DELETE FROM events WHERE chat_id IN (SELECT id FROM chats WHERE chat_identifier = ?1)",
200 rusqlite::params![chat_identifier],
201 ).map_err(|e| format!("Failed to delete chat events: {}", e))?;
202 conn.execute(
203 "DELETE FROM chats WHERE chat_identifier = ?1",
204 rusqlite::params![chat_identifier],
205 ).map_err(|e| format!("Failed to delete chat: {}", e))?;
206 super::id_cache::forget_chat_id(chat_identifier);
207 Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(900);
213
214 fn make_test_npub(n: u32) -> String {
215 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
216 let mut payload = vec![b'q'; 58];
217 let mut x = n as u64;
218 let mut i = 58;
219 while x > 0 && i > 0 {
220 i -= 1;
221 payload[i] = BECH32[(x as usize) % 32];
222 x /= 32;
223 }
224 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
225 }
226
227 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
228 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
229 crate::db::close_database();
230 crate::db::clear_id_caches();
233 let tmp = tempfile::tempdir().unwrap();
234 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
235 let account = make_test_npub(n);
236 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
237 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
238 crate::db::set_current_account(account.clone()).unwrap();
239 crate::db::init_database(&account).unwrap();
240 (tmp, guard)
241 }
242
243 #[test]
248 fn chat_upsert_preserves_last_read_against_empty_marker() {
249 let (_tmp, _guard) = init_test_db();
250 let chat_id = "npub1markerkeeper";
251
252 let mut slim = super::SlimChatDB {
253 id: chat_id.to_string(),
254 chat_type: crate::ChatType::DirectMessage,
255 participants: vec![],
256 last_read: "aa".repeat(32),
257 created_at: 1000,
258 metadata: crate::chat::ChatMetadata::default(),
259 muted: false,
260 wallpaper_path: String::new(),
261 wallpaper_ts: 0,
262 wallpaper_blur: 0,
263 wallpaper_dim: 50,
264 wallpaper_url: String::new(),
265 wallpaper_uploader: String::new(),
266 };
267 super::save_slim_chat(&slim).unwrap();
268
269 slim.last_read = String::new();
271 super::save_slim_chat(&slim).unwrap();
272 let chats = super::get_all_chats().unwrap();
273 let chat = chats.iter().find(|c| c.id == chat_id).expect("chat saved");
274 assert_eq!(chat.last_read, "aa".repeat(32), "empty marker must not wipe the stored one");
275
276 slim.last_read = "bb".repeat(32);
278 super::save_slim_chat(&slim).unwrap();
279 let chats = super::get_all_chats().unwrap();
280 let chat = chats.iter().find(|c| c.id == chat_id).expect("chat saved");
281 assert_eq!(chat.last_read, "bb".repeat(32), "non-empty marker advances normally");
282 }
283
284 #[test]
288 fn stub_created_non_npub_chat_survives_reload() {
289 let (_tmp, _guard) = init_test_db();
290 let channel_id = "abc123def456channelid";
291 let _ = crate::db::id_cache::get_or_create_chat_id(channel_id).unwrap();
292
293 let chats = super::get_all_chats().unwrap();
294 let found = chats.iter().find(|c| c.id == channel_id)
295 .expect("stub-created non-npub chat must survive get_all_chats");
296 assert_eq!(found.chat_type, crate::ChatType::Community);
297 }
298
299 #[test]
303 fn stub_created_dm_chat_carries_its_counterparty() {
304 let (_tmp, _guard) = init_test_db();
305 let npub = "npub1stubcounterparty";
306 let _ = crate::db::id_cache::get_or_create_chat_id(npub).unwrap();
307
308 let chats = super::get_all_chats().unwrap();
309 let found = chats.iter().find(|c| c.id == npub).expect("stub DM row exists");
310 assert_eq!(found.chat_type, crate::ChatType::DirectMessage);
311 assert_eq!(found.participants, vec![npub.to_string()], "the DM's id IS its participant");
312 }
313
314 #[test]
317 fn to_chat_heals_bare_dm_participants() {
318 let slim = super::SlimChatDB {
319 id: "npub1baredmrow".to_string(),
320 chat_type: crate::ChatType::DirectMessage,
321 participants: vec![],
322 last_read: String::new(),
323 created_at: 1000,
324 metadata: crate::chat::ChatMetadata::default(),
325 muted: false,
326 wallpaper_path: String::new(),
327 wallpaper_ts: 0,
328 wallpaper_blur: 0,
329 wallpaper_dim: 50,
330 wallpaper_url: String::new(),
331 wallpaper_uploader: String::new(),
332 };
333 let mut interner = crate::compact::NpubInterner::new();
334 let chat = slim.to_chat(&mut interner);
335 assert!(chat.has_participant("npub1baredmrow", &interner), "bare DM roster heals from the id");
336
337 let mut community = slim.clone();
339 community.id = "aabbccddeeff00".to_string();
340 community.chat_type = crate::ChatType::Community;
341 let chat = community.to_chat(&mut interner);
342 assert!(chat.participants().is_empty(), "non-DM rosters are not invented");
343 }
344}