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 handles: Vec<u16> = self.participants.iter().map(|p| interner.intern(p)).collect();
63 let mut chat = Chat::new(self.id.clone(), self.chat_type.clone(), handles);
64 chat.last_read = if self.last_read.is_empty() {
65 [0u8; 32]
66 } else {
67 encode_message_id(&self.last_read)
68 };
69 chat.created_at = self.created_at;
70 chat.metadata = self.metadata.clone();
71 chat.muted = self.muted;
72 chat.wallpaper_path = self.wallpaper_path.clone();
73 chat.wallpaper_ts = self.wallpaper_ts;
74 chat.wallpaper_blur = self.wallpaper_blur;
75 chat.wallpaper_dim = self.wallpaper_dim;
76 chat.wallpaper_url = self.wallpaper_url.clone();
77 chat.wallpaper_uploader = self.wallpaper_uploader.clone();
78 chat
79 }
80}
81
82pub fn get_all_chats() -> Result<Vec<SlimChatDB>, String> {
84 let conn = super::get_db_connection_guard_static()?;
85
86 let mut stmt = conn.prepare(
88 "SELECT chat_identifier, chat_type, participants, last_read, created_at, metadata, muted, \
89 wallpaper_path, wallpaper_ts, wallpaper_blur, wallpaper_dim, \
90 wallpaper_url, wallpaper_uploader \
91 FROM chats WHERE chat_type != 1 ORDER BY created_at DESC"
92 ).map_err(|e| format!("Failed to prepare statement: {}", e))?;
93
94 let rows = stmt.query_map([], |row| {
95 let participants_json: String = row.get(2)?;
96 let participants: Vec<String> = serde_json::from_str(&participants_json).unwrap_or_default();
97
98 let metadata_json: String = row.get(5)?;
99 let metadata: ChatMetadata = serde_json::from_str(&metadata_json).unwrap_or_default();
100
101 let chat_type_int: i32 = row.get(1)?;
102 let chat_type = ChatType::from_i32(chat_type_int);
103
104 Ok(SlimChatDB {
105 id: row.get(0)?,
106 chat_type,
107 participants,
108 last_read: row.get(3)?,
109 created_at: row.get::<_, i64>(4)? as u64,
110 metadata,
111 muted: row.get::<_, i32>(6)? != 0,
112 wallpaper_path: row.get(7)?,
113 wallpaper_ts: row.get::<_, i64>(8)? as u64,
114 wallpaper_blur: row.get::<_, i32>(9)?.clamp(0, 30) as u8,
115 wallpaper_dim: row.get::<_, i32>(10)?.clamp(0, 100) as u8,
116 wallpaper_url: row.get(11)?,
117 wallpaper_uploader: row.get(12)?,
118 })
119 }).map_err(|e| format!("Failed to query chats: {}", e))?;
120
121 rows.collect::<Result<Vec<_>, _>>()
122 .map_err(|e| format!("Failed to collect chats: {}", e))
123}
124
125pub fn save_slim_chat(slim_chat: &SlimChatDB) -> Result<(), String> {
127 let conn = super::get_write_connection_guard_static()?;
128
129 let chat_type_int = slim_chat.chat_type.to_i32();
130 let participants_json = serde_json::to_string(&slim_chat.participants)
131 .unwrap_or_else(|_| "[]".to_string());
132 let metadata_json = serde_json::to_string(&slim_chat.metadata)
133 .unwrap_or_else(|_| "{}".to_string());
134
135 conn.execute(
136 "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) \
142 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
143 ON CONFLICT(chat_identifier) DO UPDATE SET \
144 chat_type = excluded.chat_type, participants = excluded.participants, \
145 last_read = CASE WHEN excluded.last_read = '' THEN chats.last_read ELSE excluded.last_read END, \
146 metadata = excluded.metadata, muted = excluded.muted, \
147 wallpaper_path = excluded.wallpaper_path, wallpaper_ts = excluded.wallpaper_ts, \
148 wallpaper_blur = excluded.wallpaper_blur, wallpaper_dim = excluded.wallpaper_dim, \
149 wallpaper_url = excluded.wallpaper_url, wallpaper_uploader = excluded.wallpaper_uploader",
150 rusqlite::params![
151 slim_chat.id,
152 chat_type_int,
153 participants_json,
154 slim_chat.last_read,
155 slim_chat.created_at as i64,
156 metadata_json,
157 slim_chat.muted as i32,
158 slim_chat.wallpaper_path,
159 slim_chat.wallpaper_ts as i64,
160 slim_chat.wallpaper_blur as i32,
161 slim_chat.wallpaper_dim as i32,
162 slim_chat.wallpaper_url,
163 slim_chat.wallpaper_uploader,
164 ],
165 ).map_err(|e| format!("Failed to upsert chat: {}", e))?;
166
167 Ok(())
168}
169
170pub fn delete_chat(chat_identifier: &str) -> Result<(), String> {
173 let conn = super::get_write_connection_guard_static()?;
174 conn.execute(
176 "DELETE FROM events WHERE chat_id IN (SELECT id FROM chats WHERE chat_identifier = ?1)",
177 rusqlite::params![chat_identifier],
178 ).map_err(|e| format!("Failed to delete chat events: {}", e))?;
179 conn.execute(
180 "DELETE FROM chats WHERE chat_identifier = ?1",
181 rusqlite::params![chat_identifier],
182 ).map_err(|e| format!("Failed to delete chat: {}", e))?;
183 super::id_cache::forget_chat_id(chat_identifier);
184 Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(900);
190
191 fn make_test_npub(n: u32) -> String {
192 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
193 let mut payload = vec![b'q'; 58];
194 let mut x = n as u64;
195 let mut i = 58;
196 while x > 0 && i > 0 {
197 i -= 1;
198 payload[i] = BECH32[(x as usize) % 32];
199 x /= 32;
200 }
201 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
202 }
203
204 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
205 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
206 crate::db::close_database();
207 crate::db::clear_id_caches();
210 let tmp = tempfile::tempdir().unwrap();
211 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
212 let account = make_test_npub(n);
213 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
214 crate::db::set_app_data_dir(tmp.path().to_path_buf());
215 crate::db::set_current_account(account.clone()).unwrap();
216 crate::db::init_database(&account).unwrap();
217 (tmp, guard)
218 }
219
220 #[test]
225 fn chat_upsert_preserves_last_read_against_empty_marker() {
226 let (_tmp, _guard) = init_test_db();
227 let chat_id = "npub1markerkeeper";
228
229 let mut slim = super::SlimChatDB {
230 id: chat_id.to_string(),
231 chat_type: crate::ChatType::DirectMessage,
232 participants: vec![],
233 last_read: "aa".repeat(32),
234 created_at: 1000,
235 metadata: crate::chat::ChatMetadata::default(),
236 muted: false,
237 wallpaper_path: String::new(),
238 wallpaper_ts: 0,
239 wallpaper_blur: 0,
240 wallpaper_dim: 50,
241 wallpaper_url: String::new(),
242 wallpaper_uploader: String::new(),
243 };
244 super::save_slim_chat(&slim).unwrap();
245
246 slim.last_read = String::new();
248 super::save_slim_chat(&slim).unwrap();
249 let chats = super::get_all_chats().unwrap();
250 let chat = chats.iter().find(|c| c.id == chat_id).expect("chat saved");
251 assert_eq!(chat.last_read, "aa".repeat(32), "empty marker must not wipe the stored one");
252
253 slim.last_read = "bb".repeat(32);
255 super::save_slim_chat(&slim).unwrap();
256 let chats = super::get_all_chats().unwrap();
257 let chat = chats.iter().find(|c| c.id == chat_id).expect("chat saved");
258 assert_eq!(chat.last_read, "bb".repeat(32), "non-empty marker advances normally");
259 }
260
261 #[test]
265 fn stub_created_non_npub_chat_survives_reload() {
266 let (_tmp, _guard) = init_test_db();
267 let channel_id = "abc123def456channelid";
268 let _ = crate::db::id_cache::get_or_create_chat_id(channel_id).unwrap();
269
270 let chats = super::get_all_chats().unwrap();
271 let found = chats.iter().find(|c| c.id == channel_id)
272 .expect("stub-created non-npub chat must survive get_all_chats");
273 assert_eq!(found.chat_type, crate::ChatType::Community);
274 }
275}