1use crate::stored_event::{StoredEvent, event_kind};
4use rusqlite::OptionalExtension;
5use crate::crypto::maybe_encrypt;
6use crate::types::{Message, Attachment, Reaction};
7
8async fn encrypt_event_content(event: &StoredEvent) -> String {
16 if event.kind == event_kind::CHAT_MESSAGE
17 || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
18 || event.kind == event_kind::MESSAGE_EDIT
19 {
20 maybe_encrypt(event.content.clone()).await
21 } else {
22 event.content.clone()
23 }
24}
25
26fn insert_event_row(conn: &rusqlite::Connection, event: &StoredEvent, content: &str, tags_json: &str) -> Result<(), String> {
33 let mut stmt = conn.prepare_cached(
36 r#"
37 INSERT INTO events (
38 id, kind, chat_id, user_id, content, tags, reference_id,
39 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata
40 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
41 ON CONFLICT(id) DO UPDATE SET
42 kind = excluded.kind, chat_id = excluded.chat_id, user_id = excluded.user_id,
43 content = excluded.content, tags = excluded.tags, reference_id = excluded.reference_id,
44 created_at = excluded.created_at, received_at = excluded.received_at,
45 mine = excluded.mine, pending = excluded.pending, failed = excluded.failed,
46 wrapper_event_id = COALESCE(excluded.wrapper_event_id, events.wrapper_event_id),
47 npub = excluded.npub, preview_metadata = excluded.preview_metadata
48 "#,
49 ).map_err(|e| format!("prepare save event: {}", e))?;
50 stmt.execute(
51 rusqlite::params![
52 event.id, event.kind as i32, event.chat_id, event.user_id, content, tags_json,
53 event.reference_id, event.created_at as i64, event.received_at as i64,
54 event.mine as i32, event.pending as i32, event.failed as i32,
55 event.wrapper_event_id, event.npub, event.preview_metadata,
56 ],
57 ).map_err(|e| format!("Failed to save event: {}", e))?;
58 Ok(())
59}
60
61pub async fn save_event(event: &StoredEvent) -> Result<(), String> {
62 let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
63 let content = encrypt_event_content(event).await;
64 let conn = super::get_write_connection_guard_static()?;
65 insert_event_row(&conn, event, &content, &tags_json)
66}
67
68fn extract_bot_tags(tags: &[Vec<String>]) -> Vec<String> {
71 tags.iter()
72 .filter(|t| t.len() >= 2 && t[0] == "bot")
73 .map(|t| t[1].clone())
74 .collect()
75}
76
77fn extract_expiration_tag(tags: &[Vec<String>]) -> Option<u64> {
80 tags.iter()
81 .find(|t| t.len() >= 2 && t[0] == "expiration")
82 .and_then(|t| t[1].parse::<u64>().ok())
83}
84
85pub fn event_exists(event_id: &str) -> Result<bool, String> {
87 let conn = super::get_db_connection_guard_static()?;
88 event_exists_on(&conn, event_id)
89}
90
91fn event_exists_on(conn: &rusqlite::Connection, event_id: &str) -> Result<bool, String> {
94 let mut stmt = conn.prepare_cached("SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)")
95 .map_err(|e| format!("prepare event existence: {}", e))?;
96 stmt.query_row(rusqlite::params![event_id], |row| row.get(0))
97 .map_err(|e| format!("Failed to check event existence: {}", e))
98}
99
100fn reaction_to_stored_event(
102 reaction: &Reaction,
103 chat_id: i64,
104 user_id: Option<i64>,
105 mine: bool,
106 wrapper_event_id: Option<String>,
107) -> StoredEvent {
108 let mut tags: Vec<Vec<String>> = vec![
112 vec!["e".to_string(), reaction.reference_id.clone()],
113 ];
114 if let Some(url) = &reaction.emoji_url {
115 if reaction.emoji.starts_with(':') && reaction.emoji.ends_with(':') && reaction.emoji.len() >= 3 {
116 let shortcode = &reaction.emoji[1..reaction.emoji.len() - 1];
117 if !shortcode.is_empty() && !url.is_empty() {
118 tags.push(vec!["emoji".to_string(), shortcode.to_string(), url.clone()]);
119 }
120 }
121 }
122 StoredEvent {
123 id: reaction.id.clone(),
124 kind: event_kind::REACTION,
125 chat_id,
126 user_id,
127 content: reaction.emoji.clone(),
128 tags,
129 reference_id: Some(reaction.reference_id.clone()),
130 created_at: std::time::SystemTime::now()
131 .duration_since(std::time::UNIX_EPOCH)
132 .map(|d| d.as_secs()).unwrap_or(0),
133 received_at: std::time::SystemTime::now()
134 .duration_since(std::time::UNIX_EPOCH)
135 .map(|d| d.as_millis() as u64).unwrap_or(0),
136 mine,
137 pending: false,
138 failed: false,
139 wrapper_event_id,
140 npub: Some(reaction.author_id.clone()),
141 preview_metadata: None,
142 }
143}
144
145pub async fn save_reaction_event(
147 reaction: &Reaction,
148 chat_id: i64,
149 user_id: Option<i64>,
150 mine: bool,
151 wrapper_event_id: Option<String>,
152) -> Result<(), String> {
153 let event = reaction_to_stored_event(reaction, chat_id, user_id, mine, wrapper_event_id);
154 save_event(&event).await
155}
156
157pub async fn save_message(chat_id: &str, message: &Message) -> Result<(), String> {
166 let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
167
168 let user_int_id = if let Some(ref npub_str) = message.npub {
169 super::id_cache::get_or_create_user_id(npub_str)?
170 } else {
171 None
172 };
173
174 let event = message_to_stored_event(message, chat_int_id, user_int_id);
175
176 let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
181 let content = encrypt_event_content(&event).await;
182 {
183 let conn = super::get_write_connection_guard_static()?;
184 let tx = conn.unchecked_transaction().map_err(|e| format!("save_message tx: {e}"))?;
185 insert_event_row(&tx, &event, &content, &tags_json)?;
186 super::attachments::insert_attachment_rows(&tx, &message.id, &message.attachments)?;
187 tx.commit().map_err(|e| format!("save_message commit: {e}"))?;
188 }
189
190 for reaction in &message.reactions {
192 if !event_exists(&reaction.id)? {
193 let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
194 let is_mine = super::get_current_account()
195 .map(|npub| reaction.author_id == npub)
196 .unwrap_or(false);
197 save_reaction_event(reaction, chat_int_id, user_id, is_mine, None).await?;
198 }
199 }
200
201 Ok(())
202}
203
204struct BatchRow<'a> {
208 message: &'a Message,
209 event: StoredEvent,
210 content: String,
211 tags_json: String,
212 reactions: Vec<(StoredEvent, String)>,
213 wrapper: Option<([u8; 32], u64)>,
217}
218
219async fn prepare_batch_rows<'a>(
223 chat_id: &str,
224 messages: &[(&'a Message, Option<([u8; 32], u64)>)],
225 rows: &mut Vec<BatchRow<'a>>,
226) -> Result<(), String> {
227 let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
228 let my_npub = super::get_current_account();
229 for (message, wrapper) in messages {
230 let user_int_id = match &message.npub {
231 Some(npub_str) => super::id_cache::get_or_create_user_id(npub_str)?,
232 None => None,
233 };
234 let event = message_to_stored_event(message, chat_int_id, user_int_id);
235 let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
236 let content = encrypt_event_content(&event).await;
237 let mut reactions: Vec<(StoredEvent, String)> = Vec::with_capacity(message.reactions.len());
238 for reaction in &message.reactions {
239 let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
240 let is_mine = my_npub.as_deref().map(|n| reaction.author_id == n).unwrap_or(false);
241 let rev = reaction_to_stored_event(reaction, chat_int_id, user_id, is_mine, None);
242 let rtags = serde_json::to_string(&rev.tags).unwrap_or_else(|_| "[]".to_string());
243 reactions.push((rev, rtags));
244 }
245 rows.push(BatchRow { message, event, content, tags_json, reactions, wrapper: *wrapper });
246 }
247 Ok(())
248}
249
250fn write_batch_rows(rows: &[BatchRow<'_>]) -> Result<usize, String> {
256 let conn = super::get_write_connection_guard_static()?;
257 let tx = conn.unchecked_transaction().map_err(|e| format!("batch tx: {e}"))?;
258 let mut saved = 0usize;
259 for row in rows {
260 tx.execute_batch("SAVEPOINT batch_row").map_err(|e| format!("batch savepoint: {e}"))?;
265 let row_written = insert_event_row(&tx, &row.event, &row.content, &row.tags_json)
266 .and_then(|_| super::attachments::insert_attachment_rows(&tx, &row.message.id, &row.message.attachments));
267 if let Err(e) = row_written {
268 crate::log_warn!("[DB] batch skip {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
269 let _ = tx.execute_batch("ROLLBACK TO batch_row; RELEASE batch_row");
270 continue;
271 }
272 saved += 1;
273 for (rev, rtags) in &row.reactions {
274 if event_exists_on(&tx, &rev.id).unwrap_or(true) {
277 continue;
278 }
279 if let Err(e) = insert_event_row(&tx, rev, &rev.content, rtags) {
280 crate::log_warn!("[DB] batch reaction {}: {}", &rev.id[..8.min(rev.id.len())], e);
281 }
282 }
283 if let Some((wrapper_id, wrapper_created_at)) = &row.wrapper {
285 let mut stmt = tx.prepare_cached(
286 "INSERT OR IGNORE INTO processed_wrappers (wrapper_id, wrapper_created_at, transport) VALUES (?1, ?2, ?3)",
287 ).map_err(|e| format!("prepare wrapper ledger: {e}"))?;
288 if let Err(e) = stmt.execute(rusqlite::params![
289 &wrapper_id[..], *wrapper_created_at as i64, super::wrappers::TRANSPORT_NIP17,
290 ]) {
291 crate::log_warn!("[DB] batch wrapper ledger {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
292 }
293 }
294 tx.execute_batch("RELEASE batch_row").map_err(|e| format!("batch release: {e}"))?;
295 }
296 tx.commit().map_err(|e| format!("batch commit: {e}"))?;
297 Ok(saved)
298}
299
300pub async fn save_messages_batch(
313 chat_id: &str,
314 messages: &[&Message],
315) -> Result<usize, String> {
316 crate::db::scoped(async move {
317 if messages.is_empty() {
318 return Ok(0);
319 }
320 let with_wrappers: Vec<(&Message, Option<([u8; 32], u64)>)> =
321 messages.iter().map(|m| (*m, None)).collect();
322 let mut rows = Vec::with_capacity(messages.len());
323 prepare_batch_rows(chat_id, &with_wrappers, &mut rows).await?;
324 write_batch_rows(&rows)
325 })
326 .await
327}
328
329pub async fn save_messages_batch_multi(
334 groups: &[(String, Vec<(&Message, Option<([u8; 32], u64)>)>)],
335) -> Result<usize, String> {
336 crate::db::scoped(async move {
337 let total: usize = groups.iter().map(|(_, m)| m.len()).sum();
338 if total == 0 {
339 return Ok(0);
340 }
341 let mut rows = Vec::with_capacity(total);
342 for (chat_id, messages) in groups {
343 prepare_batch_rows(chat_id, messages, &mut rows).await?;
344 }
345 write_batch_rows(&rows)
346 })
347 .await
348}
349
350fn message_to_stored_event(message: &Message, chat_id: i64, user_id: Option<i64>) -> StoredEvent {
352 let kind = if !message.attachments.is_empty() {
353 event_kind::FILE_ATTACHMENT
354 } else {
355 event_kind::PRIVATE_DIRECT_MESSAGE
356 };
357
358 let mut tags: Vec<Vec<String>> = Vec::new();
359
360 let ms = message.at % 1000;
362 if ms > 0 {
363 tags.push(vec!["ms".to_string(), ms.to_string()]);
364 }
365
366 if !message.replied_to.is_empty() {
368 tags.push(vec![
369 "e".to_string(),
370 message.replied_to.clone(),
371 "".to_string(),
372 "reply".to_string(),
373 ]);
374 }
375
376 for et in &message.emoji_tags {
381 tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
382 }
383
384 for npub in &message.addressed_bots {
387 tags.push(vec!["bot".to_string(), npub.clone()]);
388 }
389
390 if let Some(exp) = message.expiration {
393 tags.push(vec!["expiration".to_string(), exp.to_string()]);
394 }
395
396 let preview_metadata = message.preview_metadata.as_ref()
397 .and_then(|m| serde_json::to_string(m).ok());
398
399 StoredEvent {
400 id: message.id.clone(),
401 kind,
402 chat_id,
403 user_id,
404 content: message.content.clone(),
405 tags,
406 reference_id: None,
407 created_at: message.at / 1000,
408 received_at: std::time::SystemTime::now()
409 .duration_since(std::time::UNIX_EPOCH)
410 .map(|d| d.as_millis() as u64)
411 .unwrap_or(0),
412 mine: message.mine,
413 pending: message.pending,
414 failed: message.failed,
415 wrapper_event_id: message.wrapper_event_id.clone(),
416 npub: message.npub.clone(),
417 preview_metadata,
418 }
419}
420
421pub async fn save_pivx_payment_event(
423 conversation_id: &str,
424 mut event: StoredEvent,
425) -> Result<(), String> {
426 event.chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
427 save_event(&event).await
428}
429
430pub async fn save_system_event_by_id(
433 event_id: &str,
434 conversation_id: &str,
435 event_type: crate::stored_event::SystemEventType,
436 member_npub: &str,
437 member_name: Option<&str>,
438) -> Result<bool, String> {
439 let now_secs = std::time::SystemTime::now()
440 .duration_since(std::time::UNIX_EPOCH)
441 .map(|d| d.as_secs()).unwrap_or(0);
442 save_system_event_at(event_id, conversation_id, event_type, member_npub, member_name, now_secs, None, None).await
443}
444
445pub async fn save_system_event_at(
449 event_id: &str,
450 conversation_id: &str,
451 event_type: crate::stored_event::SystemEventType,
452 member_npub: &str,
453 member_name: Option<&str>,
454 created_at_secs: u64,
455 invited_by: Option<&str>,
458 invited_label: Option<&str>,
459) -> Result<bool, String> {
460 if conversation_id.trim().is_empty() {
469 return Err("system event has no conversation id".to_string());
470 }
471 let chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
472
473 let now_secs = std::time::SystemTime::now()
474 .duration_since(std::time::UNIX_EPOCH)
475 .map(|d| d.as_secs()).unwrap_or(0);
476 let created_at = created_at_secs.min(now_secs);
478
479 let display_name = member_name.unwrap_or(member_npub);
480 let content = event_type.display_message(display_name);
481
482 let mut tags: Vec<Vec<String>> = vec![
483 vec!["d".to_string(), "system-event".to_string()],
484 vec!["event-type".to_string(), event_type.as_u8().to_string()],
485 vec!["member".to_string(), member_npub.to_string()],
486 ];
487 if let Some(by) = invited_by {
488 tags.push(vec!["invited-by".to_string(), by.to_string()]);
489 if let Some(l) = invited_label.filter(|l| !l.is_empty()) {
490 tags.push(vec!["invited-label".to_string(), l.to_string()]);
491 }
492 }
493 let tags_json = serde_json::to_string(&tags)
494 .map_err(|e| format!("Failed to serialize tags: {}", e))?;
495
496 let conn = super::get_write_connection_guard_static()?;
497 let rows = conn.execute(
498 r#"INSERT OR IGNORE INTO events (
499 id, kind, chat_id, user_id, content, tags, reference_id,
500 created_at, received_at, mine, pending, failed, wrapper_event_id, npub
501 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
502 rusqlite::params![
503 event_id,
504 event_kind::APPLICATION_SPECIFIC as i32,
505 chat_id, None::<i64>, content, tags_json, None::<String>,
506 created_at as i64, now_secs as i64,
507 0, 0, 0, None::<String>, member_npub,
508 ],
509 ).map_err(|e| format!("Failed to save system event: {}", e))?;
510
511 Ok(rows > 0)
512}
513
514pub async fn save_edit_event(
516 edit_id: &str,
517 message_id: &str,
518 new_content: &str,
519 emoji_tags: &[crate::types::EmojiTag],
520 chat_id: i64,
521 user_id: Option<i64>,
522 npub: &str,
523) -> Result<(), String> {
524 let now = std::time::SystemTime::now()
525 .duration_since(std::time::UNIX_EPOCH).unwrap();
526
527 let mut tags = vec![
530 vec!["e".to_string(), message_id.to_string(), "".to_string(), "edit".to_string()],
531 ];
532 for et in emoji_tags {
533 tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
534 }
535
536 let event = StoredEvent {
537 id: edit_id.to_string(),
538 kind: event_kind::MESSAGE_EDIT,
539 chat_id,
540 user_id,
541 content: new_content.to_string(),
542 tags,
543 reference_id: Some(message_id.to_string()),
544 created_at: now.as_secs(),
545 received_at: now.as_millis() as u64,
546 mine: true,
547 pending: false,
548 failed: false,
549 wrapper_event_id: None,
550 npub: Some(npub.to_string()),
551 preview_metadata: None,
552 };
553
554 save_event(&event).await
555}
556
557pub async fn delete_event(event_id: &str) -> Result<(), String> {
559 let conn = super::get_write_connection_guard_static()?;
560 if let Ok(Some((chat_row, at))) = conn.query_row(
564 "SELECT chat_id, created_at FROM events WHERE id = ?1",
565 rusqlite::params![event_id],
566 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
567 ).optional() {
568 conn.execute(
569 "UPDATE chats SET last_read = COALESCE(( \
570 SELECT id FROM events WHERE chat_id = ?1 AND id != ?2 AND created_at <= ?3 \
571 ORDER BY created_at DESC, id DESC LIMIT 1), '') \
572 WHERE id = ?1 AND last_read = ?2",
573 rusqlite::params![chat_row, event_id, at],
574 ).map_err(|e| format!("read-marker retreat: {e}"))?;
575 }
576 conn.execute(
577 "DELETE FROM events WHERE id = ?1",
578 rusqlite::params![event_id],
579 ).map_err(|e| format!("Failed to delete event: {}", e))?;
580 Ok(())
581}
582
583pub fn add_message_tombstone(event_id: &str) -> Result<(), String> {
587 let conn = super::get_write_connection_guard_static()?;
588 let now = std::time::SystemTime::now()
589 .duration_since(std::time::UNIX_EPOCH)
590 .map(|d| d.as_secs() as i64)
591 .unwrap_or(0);
592 conn.execute(
593 "INSERT OR IGNORE INTO deleted_messages (event_id, deleted_at) VALUES (?1, ?2)",
594 rusqlite::params![event_id, now],
595 ).map_err(|e| format!("Failed to save delete tombstone: {}", e))?;
596 Ok(())
597}
598
599pub fn load_message_tombstones() -> Result<Vec<String>, String> {
601 let conn = super::get_db_connection_guard_static()?;
602 let mut stmt = conn.prepare("SELECT event_id FROM deleted_messages")
603 .map_err(|e| format!("Failed to prepare tombstone query: {}", e))?;
604 let ids = stmt.query_map([], |row| row.get::<_, String>(0))
605 .map_err(|e| format!("Failed to query tombstones: {}", e))?
606 .filter_map(|r| r.ok())
607 .collect();
608 Ok(ids)
609}
610
611pub fn event_author(event_id: &str) -> Result<Option<String>, String> {
614 let conn = match super::get_db_connection_guard_static() {
615 Ok(c) => c,
616 Err(_) => return Ok(None),
617 };
618 conn.query_row(
619 "SELECT npub FROM events WHERE id = ?1",
620 rusqlite::params![event_id],
621 |row| row.get::<_, Option<String>>(0),
622 )
623 .optional()
624 .map(|o| o.flatten())
625 .map_err(|e| format!("Failed to read event author: {}", e))
626}
627
628pub fn event_delete_context(event_id: &str) -> Result<Option<(String, bool, Option<String>)>, String> {
632 let conn = match super::get_db_connection_guard_static() {
633 Ok(c) => c,
634 Err(_) => return Ok(None),
635 };
636 conn.query_row(
637 "SELECT c.chat_identifier, e.mine, e.npub \
638 FROM events e JOIN chats c ON c.id = e.chat_id \
639 WHERE e.id = ?1",
640 rusqlite::params![event_id],
641 |row| {
642 Ok((
643 row.get::<_, String>(0)?,
644 row.get::<_, i32>(1)? != 0,
645 row.get::<_, Option<String>>(2)?,
646 ))
647 },
648 )
649 .optional()
650 .map_err(|e| format!("Failed to read event delete context: {}", e))
651}
652
653pub fn message_exists_in_db(message_id: &str) -> Result<bool, String> {
655 let conn = match super::get_db_connection_guard_static() {
656 Ok(c) => c,
657 Err(_) => return Ok(false),
658 };
659 conn.query_row(
660 "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
661 rusqlite::params![message_id],
662 |row| row.get(0),
663 ).map_err(|e| format!("Failed to check event existence: {}", e))
664}
665
666pub fn wrapper_event_exists(wrapper_event_id: &str) -> Result<bool, String> {
668 let conn = match super::get_db_connection_guard_static() {
669 Ok(c) => c,
670 Err(_) => return Ok(false),
671 };
672 conn.query_row(
673 "SELECT EXISTS(SELECT 1 FROM events WHERE wrapper_event_id = ?1)",
674 rusqlite::params![wrapper_event_id],
675 |row| row.get(0),
676 ).map_err(|e| format!("Failed to check wrapper event existence: {}", e))
677}
678
679pub fn update_wrapper_event_id(event_id: &str, wrapper_event_id: &str) -> Result<bool, String> {
682 let conn = match super::get_write_connection_guard_static() {
683 Ok(c) => c,
684 Err(_) => return Ok(false),
685 };
686 let rows = conn.execute(
687 "UPDATE events SET wrapper_event_id = ?1 WHERE id = ?2 AND (wrapper_event_id IS NULL OR wrapper_event_id = '')",
688 rusqlite::params![wrapper_event_id, event_id],
689 ).map_err(|e| format!("Failed to update wrapper event ID: {}", e))?;
690 Ok(rows > 0)
691}
692
693pub fn get_chat_message_count(chat_id: i64) -> Result<usize, String> {
695 let conn = super::get_db_connection_guard_static()?;
696 let count: i64 = conn.query_row(
701 &format!(
702 "SELECT COUNT(*) FROM events WHERE chat_id = ?1 AND kind IN ({}, {}, {})",
703 event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT
704 ),
705 rusqlite::params![chat_id],
706 |row| row.get(0),
707 ).map_err(|e| format!("Failed to count messages: {}", e))?;
708 Ok(count as usize)
709}
710
711pub fn get_pivx_payments_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
713 let conn = super::get_db_connection_guard_static()?;
714 let chat_id: i64 = conn.query_row(
715 "SELECT id FROM chats WHERE chat_identifier = ?1",
716 rusqlite::params![conversation_id], |row| row.get(0)
717 ).map_err(|_| "Chat not found")?;
718
719 let mut stmt = conn.prepare(
720 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
721 created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
722 FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
723 ).map_err(|e| format!("Failed to prepare: {}", e))?;
724
725 let rows = stmt.query_map(
726 rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
727 |row| {
728 let tags_json: String = row.get(5)?;
729 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
730 Ok(StoredEvent {
731 id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
732 chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
733 tags, reference_id: row.get(6)?,
734 created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
735 mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
736 failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
737 npub: row.get(13)?, preview_metadata: None,
738 })
739 }
740 ).map_err(|e| format!("Failed to query: {}", e))?;
741
742 let mut payments = Vec::new();
743 for row in rows {
744 let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
745 if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "pivx-payment") {
746 payments.push(event);
747 }
748 }
749 Ok(payments)
750}
751
752pub fn get_system_events_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
754 let conn = super::get_db_connection_guard_static()?;
755 let chat_id: i64 = conn.query_row(
756 "SELECT id FROM chats WHERE chat_identifier = ?1",
757 rusqlite::params![conversation_id], |row| row.get(0)
758 ).map_err(|_| "Chat not found")?;
759
760 let mut stmt = conn.prepare(
761 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
762 created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
763 FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
764 ).map_err(|e| format!("Failed to prepare: {}", e))?;
765
766 let rows = stmt.query_map(
767 rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
768 |row| {
769 let tags_json: String = row.get(5)?;
770 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
771 Ok(StoredEvent {
772 id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
773 chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
774 tags, reference_id: row.get(6)?,
775 created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
776 mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
777 failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
778 npub: row.get(13)?, preview_metadata: None,
779 })
780 }
781 ).map_err(|e| format!("Failed to query: {}", e))?;
782
783 let mut events = Vec::new();
784 for row in rows {
785 let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
786 if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "system-event") {
787 events.push(event);
788 }
789 }
790 Ok(events)
791}
792
793fn parse_event_row(row: &rusqlite::Row) -> rusqlite::Result<StoredEvent> {
799 let tags_json: String = row.get(5)?;
800 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
801
802 Ok(StoredEvent {
803 id: row.get(0)?,
804 kind: row.get::<_, i32>(1)? as u16,
805 chat_id: row.get(2)?,
806 user_id: row.get(3)?,
807 content: row.get(4)?,
808 tags,
809 reference_id: row.get(6)?,
810 created_at: row.get::<_, i64>(7)? as u64,
811 received_at: row.get::<_, i64>(8)? as u64,
812 mine: row.get::<_, i32>(9)? != 0,
813 pending: row.get::<_, i32>(10)? != 0,
814 failed: row.get::<_, i32>(11)? != 0,
815 wrapper_event_id: row.get(12)?,
816 npub: row.get(13)?,
817 preview_metadata: row.get(14)?,
818 })
819}
820
821pub async fn get_events(
824 chat_id: i64,
825 kinds: Option<&[u16]>,
826 limit: usize,
827 offset: usize,
828) -> Result<Vec<StoredEvent>, String> {
829 let events: Vec<StoredEvent> = {
830 let conn = super::get_db_connection_guard_static()?;
831
832 if let Some(k) = kinds {
833 let kind_placeholders: String = (0..k.len())
834 .map(|i| format!("?{}", i + 2))
835 .collect::<Vec<_>>()
836 .join(",");
837 let limit_param = k.len() + 2;
838 let offset_param = k.len() + 3;
839
840 let sql = format!(
841 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
842 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
843 FROM events WHERE chat_id = ?1 AND kind IN ({}) \
844 ORDER BY created_at DESC, received_at DESC \
845 LIMIT ?{} OFFSET ?{}",
846 kind_placeholders, limit_param, offset_param
847 );
848
849 let mut stmt = conn.prepare(&sql)
850 .map_err(|e| format!("Failed to prepare events query: {}", e))?;
851
852 match k.len() {
853 1 => {
854 let rows = stmt.query_map(
855 rusqlite::params![chat_id, k[0] as i32, limit as i64, offset as i64],
856 parse_event_row
857 ).map_err(|e| format!("Failed to query events: {}", e))?;
858 rows.filter_map(|r| r.ok()).collect()
859 },
860 2 => {
861 let rows = stmt.query_map(
862 rusqlite::params![chat_id, k[0] as i32, k[1] as i32, limit as i64, offset as i64],
863 parse_event_row
864 ).map_err(|e| format!("Failed to query events: {}", e))?;
865 rows.filter_map(|r| r.ok()).collect()
866 },
867 3 => {
868 let rows = stmt.query_map(
869 rusqlite::params![chat_id, k[0] as i32, k[1] as i32, k[2] as i32, limit as i64, offset as i64],
870 parse_event_row
871 ).map_err(|e| format!("Failed to query events: {}", e))?;
872 rows.filter_map(|r| r.ok()).collect()
873 },
874 _ => return Err("Unsupported number of kinds".to_string()),
875 }
876 } else {
877 let mut stmt = conn.prepare(
878 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
879 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
880 FROM events WHERE chat_id = ?1 \
881 ORDER BY created_at DESC, received_at DESC \
882 LIMIT ?2 OFFSET ?3"
883 ).map_err(|e| format!("Failed to prepare events query: {}", e))?;
884
885 let rows = stmt.query_map(
886 rusqlite::params![chat_id, limit as i64, offset as i64],
887 parse_event_row
888 ).map_err(|e| format!("Failed to query events: {}", e))?;
889 rows.filter_map(|r| r.ok()).collect()
890 }
891 };
892
893 let mut decrypted = Vec::with_capacity(events.len());
895 for mut event in events {
896 if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
897 event.content = crate::crypto::maybe_decrypt(event.content).await
898 .unwrap_or_else(|_| "[Decryption failed]".to_string());
899 }
900 decrypted.push(event);
901 }
902
903 Ok(decrypted)
904}
905
906pub async fn get_related_events(
908 reference_ids: &[String],
909) -> Result<Vec<StoredEvent>, String> {
910 if reference_ids.is_empty() {
911 return Ok(Vec::new());
912 }
913
914 let conn = super::get_db_connection_guard_static()?;
915
916 let placeholders: String = reference_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
917 let sql = format!(
918 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
919 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
920 FROM events WHERE reference_id IN ({}) \
921 ORDER BY created_at ASC, received_at ASC",
922 placeholders
923 );
924
925 let mut stmt = conn.prepare(&sql)
926 .map_err(|e| format!("Failed to prepare related events query: {}", e))?;
927
928 let params: Vec<&dyn rusqlite::ToSql> = reference_ids.iter()
929 .map(|s| s as &dyn rusqlite::ToSql)
930 .collect();
931
932 let events: Vec<StoredEvent> = stmt.query_map(params.as_slice(), parse_event_row)
933 .map_err(|e| format!("Failed to query related events: {}", e))?
934 .filter_map(|r| r.ok())
935 .collect();
936
937 Ok(events)
938}
939
940pub struct ReplyContext {
942 pub content: String,
943 pub npub: Option<String>,
944 pub has_attachment: bool,
945 pub extension: Option<String>,
948 pub emoji_tags: Vec<crate::types::EmojiTag>,
951}
952
953pub async fn get_reply_contexts(
955 message_ids: &[String],
956) -> Result<std::collections::HashMap<String, ReplyContext>, String> {
957 use std::collections::HashMap;
958
959 if message_ids.is_empty() {
960 return Ok(HashMap::new());
961 }
962
963 let (events, edits): (Vec<(String, i32, String, Option<String>, Option<String>)>, Vec<(String, String, Option<String>)>) = {
964 let conn = super::get_db_connection_guard_static()?;
965
966 let placeholders: String = (0..message_ids.len())
967 .map(|i| format!("?{}", i + 1))
968 .collect::<Vec<_>>()
969 .join(",");
970
971 let sql = format!(
973 "SELECT id, kind, content, npub, tags FROM events WHERE id IN ({})",
974 placeholders
975 );
976 let mut stmt = conn.prepare(&sql)
977 .map_err(|e| format!("Failed to prepare reply context query: {}", e))?;
978
979 let params: Vec<&str> = message_ids.iter().map(|s| s.as_str()).collect();
980 let params_dyn: Vec<&dyn rusqlite::ToSql> = params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
981
982 let rows = stmt.query_map(params_dyn.as_slice(), |row| {
983 Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?,
984 row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?,
985 row.get::<_, Option<String>>(4)?))
986 }).map_err(|e| format!("Failed to query reply contexts: {}", e))?;
987 let events_result: Vec<_> = rows.filter_map(|r| r.ok()).collect();
988 drop(stmt);
989
990 let edit_sql = format!(
992 "SELECT reference_id, content, tags FROM events \
993 WHERE kind = {} AND reference_id IN ({}) \
994 ORDER BY created_at DESC, received_at DESC",
995 event_kind::MESSAGE_EDIT, placeholders
996 );
997 let mut edit_stmt = conn.prepare(&edit_sql)
998 .map_err(|e| format!("Failed to prepare edit query: {}", e))?;
999 let edit_rows = edit_stmt.query_map(params_dyn.as_slice(), |row| {
1000 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?,
1001 row.get::<_, Option<String>>(2)?))
1002 }).map_err(|e| format!("Failed to query edits: {}", e))?;
1003 let edits_result: Vec<_> = edit_rows.filter_map(|r| r.ok()).collect();
1004
1005 (events_result, edits_result)
1006 };
1007
1008 let mut latest_edits: HashMap<String, (String, Option<String>)> = HashMap::new();
1010 for (ref_id, content, tags) in edits {
1011 latest_edits.entry(ref_id).or_insert((content, tags));
1012 }
1013
1014 let file_ids: Vec<String> = events.iter()
1016 .filter(|(_, kind, _, _, _)| *kind == event_kind::FILE_ATTACHMENT as i32)
1017 .map(|(id, _, _, _, _)| id.clone())
1018 .collect();
1019 let atts_by_event = super::attachments::get_attachments_for_events(&file_ids).unwrap_or_default();
1020
1021 let mut contexts = HashMap::new();
1023 for (id, kind, original_content, npub, tags) in events {
1024 let has_attachment = kind == event_kind::FILE_ATTACHMENT as i32;
1025 let latest_edit = latest_edits.get(&id);
1026 let content_to_decrypt = latest_edit
1027 .map(|(c, _)| c.clone())
1028 .unwrap_or(original_content);
1029
1030 let parse_emoji = |json: &Option<String>| -> Vec<crate::types::EmojiTag> {
1033 json.as_deref()
1034 .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1035 .map(|parsed| crate::types::EmojiTag::extract_from_stored(&parsed))
1036 .unwrap_or_default()
1037 };
1038 let emoji_tags = match latest_edit {
1039 Some((_, edit_tags)) => parse_emoji(edit_tags),
1040 None => parse_emoji(&tags),
1041 };
1042
1043 let decrypted_content = if kind == event_kind::CHAT_MESSAGE as i32
1044 || kind == event_kind::PRIVATE_DIRECT_MESSAGE as i32
1045 {
1046 crate::crypto::maybe_decrypt(content_to_decrypt).await
1047 .unwrap_or_else(|_| "[Decryption failed]".to_string())
1048 } else {
1049 String::new()
1050 };
1051
1052 let extension = if has_attachment {
1055 atts_by_event.get(&id)
1056 .and_then(|atts| atts.first())
1057 .map(|a| a.extension.to_lowercase())
1058 .filter(|e| !e.is_empty())
1059 .or_else(|| tags.as_deref()
1060 .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1061 .and_then(|parsed| parsed.into_iter()
1062 .find(|t| t.first().map(|k| k == "attachments").unwrap_or(false))
1063 .and_then(|t| t.into_iter().nth(1)))
1064 .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(&json).ok())
1065 .and_then(|atts| atts.into_iter().next())
1066 .and_then(|a| a.get("extension").and_then(|e| e.as_str()).map(str::to_lowercase))
1067 .filter(|e| !e.is_empty()))
1068 } else {
1069 None
1070 };
1071
1072 contexts.insert(id, ReplyContext { content: decrypted_content, npub, has_attachment, extension, emoji_tags });
1073 }
1074
1075 Ok(contexts)
1076}
1077
1078pub async fn populate_reply_contexts(messages: Vec<&mut Message>) -> Result<(), String> {
1085 let ids: Vec<String> = messages
1086 .iter()
1087 .filter(|m| !m.replied_to.is_empty())
1088 .map(|m| m.replied_to.clone())
1089 .collect();
1090 if ids.is_empty() {
1091 return Ok(());
1092 }
1093 let contexts = get_reply_contexts(&ids).await?;
1094 for message in messages {
1095 if let Some(ctx) = contexts.get(&message.replied_to) {
1096 message.replied_to_content = Some(ctx.content.clone());
1097 message.replied_to_npub = ctx.npub.clone();
1098 message.replied_to_has_attachment = Some(ctx.has_attachment);
1099 message.replied_to_attachment_extension = ctx.extension.clone();
1100 message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1101 }
1102 }
1103 Ok(())
1104}
1105
1106pub async fn populate_reply_context(message: &mut Message) -> Result<(), String> {
1109 if message.replied_to.is_empty() {
1110 return Ok(());
1111 }
1112
1113 let contexts = get_reply_contexts(&[message.replied_to.clone()]).await?;
1114
1115 if let Some(ctx) = contexts.get(&message.replied_to) {
1116 message.replied_to_content = Some(ctx.content.clone());
1117 message.replied_to_npub = ctx.npub.clone();
1118 message.replied_to_has_attachment = Some(ctx.has_attachment);
1119 message.replied_to_attachment_extension = ctx.extension.clone();
1120 message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1121 }
1122
1123 Ok(())
1124}
1125
1126pub fn is_own_event(event_id: &str) -> bool {
1130 let Ok(conn) = super::get_db_connection_guard_static() else {
1131 return false;
1132 };
1133 conn.query_row(
1134 "SELECT mine FROM events WHERE id = ?1",
1135 [event_id],
1136 |row| row.get::<_, i64>(0),
1137 )
1138 .map(|mine| mine == 1)
1139 .unwrap_or(false)
1140}
1141
1142fn extract_tag_from_json(tags_json: &str, key: &str) -> Option<String> {
1148 if tags_json.len() <= 2 { return None; }
1149 let pattern = format!("[\"{}\"", key);
1150 if !tags_json.contains(&pattern) { return None; }
1151 let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1152 tags.into_iter()
1153 .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
1154 .and_then(|tag| tag.into_iter().nth(1))
1155}
1156
1157
1158fn normalize_reaction_author(author: String) -> String {
1162 if author.len() == 64 && author.bytes().all(|b| b.is_ascii_hexdigit()) {
1163 if let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(&author) {
1164 use nostr_sdk::prelude::ToBech32;
1165 let Ok(npub) = pk.to_bech32();
1166 return npub;
1167 }
1168 }
1169 author
1170}
1171fn extract_reaction_emoji_url(tags: &[Vec<String>], content: &str) -> Option<String> {
1177 if !content.starts_with(':') || !content.ends_with(':') || content.len() < 3 {
1178 return None;
1179 }
1180 let sc = &content[1..content.len() - 1];
1181 tags.iter().find_map(|t| {
1182 if t.len() >= 3 && t[0] == "emoji" && t[1] == sc {
1183 Some(t[2].clone())
1184 } else {
1185 None
1186 }
1187 })
1188}
1189
1190fn extract_reply_tag_from_json(tags_json: &str) -> Option<String> {
1192 if tags_json.len() <= 2 { return None; }
1193 if !tags_json.contains("[\"e\"") { return None; }
1194 let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1195 tags.into_iter()
1196 .find(|tag| {
1197 tag.first().map(|s| s.as_str()) == Some("e")
1198 && tag.get(3).map(|s| s.as_str()) == Some("reply")
1199 })
1200 .and_then(|tag| tag.into_iter().nth(1))
1201}
1202
1203pub async fn get_message_views(
1208 chat_id: i64,
1209 limit: usize,
1210 offset: usize,
1211) -> Result<Vec<Message>, String> {
1212 let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1214 let message_events = get_events(chat_id, Some(&message_kinds), limit, offset).await?;
1215
1216 compose_message_views(message_events).await
1217}
1218
1219async fn compose_message_views(message_events: Vec<StoredEvent>) -> Result<Vec<Message>, String> {
1224 use std::collections::HashMap;
1225
1226 if message_events.is_empty() {
1227 return Ok(Vec::new());
1228 }
1229
1230 let message_ids: Vec<String> = message_events.iter().map(|e| e.id.clone()).collect();
1232 let related_events = get_related_events(&message_ids).await?;
1233
1234 let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1235 let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1236
1237 for event in related_events {
1238 if let Some(ref_id) = &event.reference_id {
1239 match event.kind {
1240 k if k == event_kind::REACTION => {
1241 let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1242 let reaction = Reaction {
1243 id: event.id.clone(),
1244 reference_id: ref_id.clone(),
1245 author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1246 emoji: event.content.clone(),
1247 emoji_url,
1248 };
1249 let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1252 if !slot.iter().any(|r| r.same_slot(&reaction)) {
1253 slot.push(reaction);
1254 }
1255 }
1256 k if k == event_kind::MESSAGE_EDIT => {
1257 let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1258 .unwrap_or_else(|_| event.content.clone());
1259 let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1260 edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1261 }
1262 _ => {}
1263 }
1264 }
1265 }
1266
1267 for edits in edits_by_msg.values_mut() {
1268 edits.sort_by_key(|(ts, _, _)| *ts);
1269 }
1270
1271 let attach_ids: Vec<String> = message_events.iter()
1274 .filter(|e| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1275 .map(|e| e.id.clone())
1276 .collect();
1277 let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1278 .unwrap_or_default();
1279 for event in &message_events {
1280 if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1281 continue;
1282 }
1283 if attachments_by_msg.contains_key(&event.id) {
1284 continue;
1285 }
1286 if let Some(json) = event.get_tag("attachments") {
1287 if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(json) {
1288 if !atts.is_empty() {
1289 attachments_by_msg.insert(event.id.clone(), atts);
1290 }
1291 }
1292 }
1293 }
1294
1295 let mut messages = Vec::with_capacity(message_events.len());
1297 for event in message_events {
1298 let replied_to = event.get_reply_reference().unwrap_or("").to_string();
1299 let at = event.timestamp_ms();
1300 let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1301 let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1302
1303 let original_content = if event.kind == event_kind::FILE_ATTACHMENT {
1304 String::new()
1305 } else {
1306 event.content.clone()
1307 };
1308
1309 let original_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1312 let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1313 let mut history = Vec::with_capacity(edits.len() + 1);
1314 history.push(crate::types::EditEntry { content: original_content.clone(), edited_at: at });
1315 for (ts, c, _) in &edits {
1316 history.push(crate::types::EditEntry { content: c.clone(), edited_at: *ts });
1317 }
1318 let (latest, latest_emoji) = edits.last()
1319 .map(|(_, c, e)| (c.clone(), e.clone()))
1320 .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1321 (latest, true, Some(history), latest_emoji)
1322 } else {
1323 (original_content, false, None, original_emoji)
1324 };
1325
1326 let preview_metadata = event.preview_metadata
1327 .and_then(|json| serde_json::from_str(&json).ok());
1328
1329 let addressed_bots = extract_bot_tags(&event.tags);
1330 let expiration = extract_expiration_tag(&event.tags);
1331 messages.push(Message {
1332 expiration,
1333 id: event.id, content, replied_to,
1334 replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1335 replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1336 preview_metadata, attachments, reactions, at,
1337 pending: event.pending, failed: event.failed, mine: event.mine,
1338 npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1339 edited, edit_history,
1340 emoji_tags,
1341 addressed_bots,
1342 });
1343 }
1344
1345 crate::self_destruct::strip_expired(&mut messages);
1349
1350 let reply_ids: Vec<String> = messages.iter()
1352 .filter(|m| !m.replied_to.is_empty())
1353 .map(|m| m.replied_to.clone())
1354 .collect();
1355
1356 if !reply_ids.is_empty() {
1357 let contexts = get_reply_contexts(&reply_ids).await?;
1358 for msg in &mut messages {
1359 if let Some(ctx) = contexts.get(&msg.replied_to) {
1360 msg.replied_to_content = Some(ctx.content.clone());
1361 msg.replied_to_npub = ctx.npub.clone();
1362 msg.replied_to_has_attachment = Some(ctx.has_attachment);
1363 msg.replied_to_attachment_extension = ctx.extension.clone();
1364 }
1365 }
1366 }
1367
1368 Ok(messages)
1369}
1370
1371pub async fn get_messages_around(
1379 chat_id: i64,
1380 anchor_id: &str,
1381 before: usize,
1382 after: usize,
1383) -> Result<Vec<Message>, String> {
1384 let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1385
1386 let message_events: Vec<StoredEvent> = {
1387 let conn = super::get_db_connection_guard_static()?;
1388
1389 let (anchor_at, anchor_rt, anchor_rowid): (i64, i64, i64) = conn.query_row(
1395 "SELECT created_at, received_at, rowid FROM events WHERE id = ?1",
1396 rusqlite::params![anchor_id],
1397 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1398 ).map_err(|e| format!("Anchor message not found: {}", e))?;
1399
1400 let kind_placeholders: String = (0..message_kinds.len())
1402 .map(|i| format!("?{}", i + 2))
1403 .collect::<Vec<_>>()
1404 .join(",");
1405 let cols = "id, kind, chat_id, user_id, content, tags, reference_id, \
1406 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata";
1407
1408 let older_sql = format!(
1410 "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1411 AND (created_at < ?5 OR (created_at = ?5 AND (received_at < ?6 \
1412 OR (received_at = ?6 AND rowid <= ?7)))) \
1413 ORDER BY created_at DESC, received_at DESC, rowid DESC LIMIT ?8",
1414 cols, kind_placeholders
1415 );
1416 let mut older_stmt = conn.prepare(&older_sql)
1417 .map_err(|e| format!("Failed to prepare older window query: {}", e))?;
1418 let older_rows = older_stmt.query_map(
1419 rusqlite::params![
1420 chat_id,
1421 message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1422 anchor_at, anchor_rt, anchor_rowid, before as i64
1423 ],
1424 parse_event_row,
1425 ).map_err(|e| format!("Failed to query older window: {}", e))?;
1426 let mut older: Vec<StoredEvent> = older_rows.filter_map(|r| r.ok()).collect();
1427 older.reverse(); let newer_sql = format!(
1431 "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1432 AND (created_at > ?5 OR (created_at = ?5 AND (received_at > ?6 \
1433 OR (received_at = ?6 AND rowid > ?7)))) \
1434 ORDER BY created_at ASC, received_at ASC, rowid ASC LIMIT ?8",
1435 cols, kind_placeholders
1436 );
1437 let mut newer_stmt = conn.prepare(&newer_sql)
1438 .map_err(|e| format!("Failed to prepare newer window query: {}", e))?;
1439 let newer_rows = newer_stmt.query_map(
1440 rusqlite::params![
1441 chat_id,
1442 message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1443 anchor_at, anchor_rt, anchor_rowid, after as i64
1444 ],
1445 parse_event_row,
1446 ).map_err(|e| format!("Failed to query newer window: {}", e))?;
1447 let newer: Vec<StoredEvent> = newer_rows.filter_map(|r| r.ok()).collect();
1448
1449 older.into_iter().chain(newer).collect()
1450 };
1451
1452 let mut decrypted = Vec::with_capacity(message_events.len());
1454 for mut event in message_events {
1455 if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
1456 event.content = crate::crypto::maybe_decrypt(event.content).await
1457 .unwrap_or_else(|_| "[Decryption failed]".to_string());
1458 }
1459 decrypted.push(event);
1460 }
1461
1462 compose_message_views(decrypted).await
1463}
1464
1465pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
1468 use std::collections::HashMap;
1469
1470 let message_events: Vec<(String, StoredEvent, String)> = {
1472 let conn = super::get_db_connection_guard_static()?;
1473 let mut stmt = conn.prepare(
1474 "SELECT c.chat_identifier, \
1475 e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1476 e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, e.preview_metadata \
1477 FROM chats c JOIN events e ON e.rowid = ( \
1478 SELECT e2.rowid FROM events e2 WHERE e2.chat_id = c.id \
1479 AND e2.kind IN (?1, ?2, ?3) \
1480 ORDER BY e2.created_at DESC, e2.received_at DESC LIMIT 1) \
1481 WHERE c.chat_type != 1"
1482 ).map_err(|e| format!("Failed to prepare: {}", e))?;
1483
1484 let rows = stmt.query_map(
1485 rusqlite::params![
1486 event_kind::CHAT_MESSAGE as i32,
1487 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1488 event_kind::FILE_ATTACHMENT as i32
1489 ],
1490 |row| {
1491 let chat_id: String = row.get(0)?;
1492 let tags_json: String = row.get(6)?;
1493 let event = StoredEvent {
1494 id: row.get(1)?, kind: row.get::<_, i32>(2)? as u16,
1495 chat_id: row.get(3)?, user_id: row.get(4)?, content: row.get(5)?,
1496 tags: Vec::new(), reference_id: row.get(7)?,
1498 created_at: row.get::<_, i64>(8)? as u64, received_at: row.get::<_, i64>(9)? as u64,
1499 mine: row.get::<_, i32>(10)? != 0, pending: row.get::<_, i32>(11)? != 0,
1500 failed: row.get::<_, i32>(12)? != 0, wrapper_event_id: row.get(13)?,
1501 npub: row.get(14)?, preview_metadata: row.get(15)?,
1502 };
1503 Ok((chat_id, event, tags_json))
1504 }
1505 ).map_err(|e| format!("Failed to query: {}", e))?;
1506 rows.filter_map(|r| r.ok()).collect()
1507 };
1508
1509 if message_events.is_empty() {
1510 return Ok(HashMap::new());
1511 }
1512
1513 let message_ids: Vec<String> = message_events.iter().map(|(_, e, _)| e.id.clone()).collect();
1515 let related_events = get_related_events(&message_ids).await?;
1516
1517 let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1518 let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1519
1520 for event in related_events {
1521 if let Some(ref_id) = &event.reference_id {
1522 match event.kind {
1523 k if k == event_kind::REACTION => {
1524 let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1525 let reaction = Reaction {
1526 id: event.id.clone(), reference_id: ref_id.clone(),
1527 author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1528 emoji: event.content.clone(),
1529 emoji_url,
1530 };
1531 let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1534 if !slot.iter().any(|r| r.same_slot(&reaction)) {
1535 slot.push(reaction);
1536 }
1537 }
1538 k if k == event_kind::MESSAGE_EDIT => {
1539 let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1540 .unwrap_or_else(|_| event.content.clone());
1541 let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1542 edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1543 }
1544 _ => {}
1545 }
1546 }
1547 }
1548 for edits in edits_by_msg.values_mut() {
1549 edits.sort_by_key(|(ts, _, _)| *ts);
1550 }
1551
1552 let attach_ids: Vec<String> = message_events.iter()
1554 .filter(|(_, e, _)| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1555 .map(|(_, e, _)| e.id.clone())
1556 .collect();
1557 let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1558 .unwrap_or_default();
1559 for (_, event, tags_json) in &message_events {
1560 if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1561 continue;
1562 }
1563 if attachments_by_msg.contains_key(&event.id) {
1564 continue;
1565 }
1566 if let Some(val) = extract_tag_from_json(tags_json, "attachments") {
1567 if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&val) {
1568 if !atts.is_empty() {
1569 attachments_by_msg.insert(event.id.clone(), atts);
1570 }
1571 }
1572 }
1573 }
1574
1575 let mut result: HashMap<String, Vec<Message>> = HashMap::new();
1577
1578 for (chat_identifier, event, tags_json) in message_events {
1579 let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1580 let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1581 let replied_to = extract_reply_tag_from_json(&tags_json).unwrap_or_default();
1582
1583 let original_content = if event.kind == event_kind::CHAT_MESSAGE
1585 || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
1586 {
1587 crate::crypto::maybe_decrypt(event.content.clone()).await
1588 .unwrap_or_else(|_| "[Decryption failed]".to_string())
1589 } else {
1590 String::new()
1591 };
1592
1593 let stored_tags = serde_json::from_str::<Vec<Vec<String>>>(&tags_json).unwrap_or_default();
1594 let original_emoji = crate::types::EmojiTag::extract_from_stored(&stored_tags);
1595 let addressed_bots = extract_bot_tags(&stored_tags);
1596 let expiration = extract_expiration_tag(&stored_tags);
1597 let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1599 let (latest, latest_emoji) = edits.last()
1600 .map(|(_, c, e)| (c.clone(), e.clone()))
1601 .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1602 let history: Vec<crate::types::EditEntry> = std::iter::once(crate::types::EditEntry {
1603 content: original_content, edited_at: event.created_at * 1000,
1604 }).chain(edits.into_iter().map(|(ts, c, _)| crate::types::EditEntry { content: c, edited_at: ts }))
1605 .collect();
1606 (latest, true, Some(history), latest_emoji)
1607 } else {
1608 (original_content, false, None, original_emoji)
1609 };
1610
1611 let preview_metadata = event.preview_metadata
1612 .and_then(|json| serde_json::from_str(&json).ok());
1613
1614 result.entry(chat_identifier).or_default().push(Message {
1615 expiration,
1616 id: event.id, content, replied_to,
1617 replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1618 replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1619 preview_metadata, attachments, reactions, at: event.created_at * 1000,
1620 pending: event.pending, failed: event.failed, mine: event.mine,
1621 npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1622 edited, edit_history,
1623 emoji_tags,
1624 addressed_bots,
1625 });
1626 }
1627
1628 let reply_ids: Vec<String> = result.values()
1632 .flatten()
1633 .filter(|m| !m.replied_to.is_empty())
1634 .map(|m| m.replied_to.clone())
1635 .collect();
1636
1637 if !reply_ids.is_empty() {
1638 let contexts = get_reply_contexts(&reply_ids).await?;
1639 for msg in result.values_mut().flatten() {
1640 if let Some(ctx) = contexts.get(&msg.replied_to) {
1641 msg.replied_to_content = Some(ctx.content.clone());
1642 msg.replied_to_npub = ctx.npub.clone();
1643 msg.replied_to_has_attachment = Some(ctx.has_attachment);
1644 msg.replied_to_attachment_extension = ctx.extension.clone();
1645 }
1646 }
1647 }
1648
1649 for msgs in result.values_mut() {
1653 crate::self_destruct::strip_expired(msgs);
1654 }
1655
1656 Ok(result)
1657}
1658
1659pub async fn unread_counts() -> Result<std::collections::HashMap<String, u32>, String> {
1668 let conn = super::get_db_connection_guard_static()?;
1669 let mut stmt = conn
1675 .prepare(
1676 "WITH anchors AS ( \
1677 SELECT c.id AS chat_id, c.chat_identifier AS chat_identifier, \
1678 COALESCE(MAX(e.created_at), 0) AS anchor_ts \
1679 FROM chats c \
1680 LEFT JOIN events e ON e.chat_id = c.id \
1681 AND ((e.mine = 1 AND e.kind IN (?1, ?2, ?3)) OR e.id = c.last_read) \
1682 GROUP BY c.id \
1683 ) \
1684 SELECT a.chat_identifier, COUNT(*) AS unread \
1685 FROM events e JOIN anchors a ON a.chat_id = e.chat_id \
1686 WHERE e.kind IN (?1, ?2, ?3) AND e.mine = 0 AND e.created_at > a.anchor_ts \
1687 AND (e.npub IS NULL OR e.npub NOT IN ( \
1688 SELECT chat_identifier FROM chats WHERE muted = 1 \
1689 UNION \
1690 SELECT npub FROM profiles WHERE is_blocked = 1)) \
1691 GROUP BY a.chat_identifier",
1692 )
1693 .map_err(|e| format!("prepare unread_counts: {e}"))?;
1694 let rows = stmt
1695 .query_map(
1696 rusqlite::params![
1697 event_kind::CHAT_MESSAGE as i32,
1698 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1699 event_kind::FILE_ATTACHMENT as i32
1700 ],
1701 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)),
1702 )
1703 .map_err(|e| format!("query unread_counts: {e}"))?;
1704 let mut out = std::collections::HashMap::new();
1705 for r in rows.flatten() {
1706 out.insert(r.0, r.1);
1707 }
1708 Ok(out)
1709}
1710
1711pub async fn unread_count_for_chat(chat_identifier: &str) -> Result<u32, String> {
1714 let conn = super::get_db_connection_guard_static()?;
1715 let count: i64 = conn
1716 .query_row(
1717 "SELECT COUNT(*) FROM events e JOIN chats c ON e.chat_id = c.id \
1718 WHERE c.chat_identifier = ?4 AND e.kind IN (?1, ?2, ?3) AND e.mine = 0 \
1719 AND (e.npub IS NULL OR e.npub NOT IN ( \
1720 SELECT chat_identifier FROM chats WHERE muted = 1 \
1721 UNION \
1722 SELECT npub FROM profiles WHERE is_blocked = 1)) \
1723 AND e.created_at > COALESCE(( \
1724 SELECT MAX(e2.created_at) FROM events e2 \
1725 WHERE e2.chat_id = c.id \
1726 AND ((e2.mine = 1 AND e2.kind IN (?1, ?2, ?3)) OR e2.id = c.last_read)), 0)",
1727 rusqlite::params![
1728 event_kind::CHAT_MESSAGE as i32,
1729 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1730 event_kind::FILE_ATTACHMENT as i32,
1731 chat_identifier
1732 ],
1733 |row| row.get(0),
1734 )
1735 .map_err(|e| format!("query unread_count_for_chat: {e}"))?;
1736 Ok(count as u32)
1737}
1738
1739#[derive(Debug, PartialEq)]
1743pub enum UnreadMark {
1744 NoOp,
1746 Clear,
1748 Anchor(String),
1750}
1751
1752pub async fn compute_unread_anchor(chat_identifier: &str) -> Result<UnreadMark, String> {
1756 let conn = super::get_db_connection_guard_static()?;
1757 let (k0, k1, k2) = (
1758 event_kind::CHAT_MESSAGE as i32,
1759 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1760 event_kind::FILE_ATTACHMENT as i32,
1761 );
1762 let (target_ts, newest_ts): (Option<i64>, Option<i64>) = conn
1764 .query_row(
1765 "SELECT MAX(CASE WHEN e.mine = 0 THEN e.created_at END), MAX(e.created_at) \
1766 FROM events e JOIN chats c ON e.chat_id = c.id \
1767 WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4)",
1768 rusqlite::params![chat_identifier, k0, k1, k2],
1769 |row| Ok((row.get(0)?, row.get(1)?)),
1770 )
1771 .map_err(|e| format!("unread anchor target: {e}"))?;
1772
1773 let target_ts = match target_ts {
1774 Some(t) => t,
1775 None => return Ok(UnreadMark::NoOp), };
1777 if newest_ts.map_or(false, |n| n > target_ts) {
1778 return Ok(UnreadMark::NoOp); }
1780
1781 let anchor_id: Option<String> = conn
1782 .query_row(
1783 "SELECT e.id FROM events e JOIN chats c ON e.chat_id = c.id \
1784 WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4) AND e.created_at < ?5 \
1785 ORDER BY e.created_at DESC LIMIT 1",
1786 rusqlite::params![chat_identifier, k0, k1, k2, target_ts],
1787 |row| row.get(0),
1788 )
1789 .optional()
1790 .map_err(|e| format!("unread anchor prev: {e}"))?;
1791
1792 Ok(match anchor_id {
1793 Some(id) => UnreadMark::Anchor(id),
1794 None => UnreadMark::Clear,
1795 })
1796}
1797
1798pub async fn flush_message_batch(
1805 chat_id: &str,
1806 pending: &mut Vec<&Message>,
1807 session: &std::sync::Arc<crate::db::Session>,
1808) {
1809 crate::db::scoped(async move {
1810 if pending.is_empty() {
1811 return;
1812 }
1813 if !session.is_live() {
1814 pending.clear();
1815 return;
1816 }
1817 if let Err(e) = save_messages_batch(chat_id, pending).await {
1818 crate::log_warn!("[DB] batch flush failed for {}: {}", chat_id, e);
1819 }
1820 pending.clear();
1821 })
1822 .await
1823}
1824
1825pub async fn save_chat_messages(chat_id: &str, messages: &[Message]) -> Result<(), String> {
1827 if messages.is_empty() {
1828 return Ok(());
1829 }
1830 let refs: Vec<&Message> = messages.iter().collect();
1831 save_messages_batch(chat_id, &refs).await.map(|_| ())
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836 use super::*;
1837 use crate::stored_event::SystemEventType;
1838
1839 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(71000);
1840
1841 fn make_test_npub(n: u32) -> String {
1842 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1843 let mut payload = vec![b'q'; 58];
1844 let mut x = n as u64;
1845 let mut i = 58;
1846 while x > 0 && i > 0 {
1847 i -= 1;
1848 payload[i] = BECH32[(x as usize) % 32];
1849 x /= 32;
1850 }
1851 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1852 }
1853
1854 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1855 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1856 crate::db::close_database();
1857 crate::db::clear_id_caches();
1860 let tmp = tempfile::tempdir().unwrap();
1861 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1862 let account = make_test_npub(n);
1863 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1864 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1865 crate::db::set_current_account(account.clone()).unwrap();
1866 crate::db::init_database(&account).unwrap();
1867 (tmp, guard)
1868 }
1869
1870 fn now_secs() -> u64 {
1871 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
1872 }
1873
1874 #[tokio::test]
1881 async fn batch_resolver_fills_quotes_for_a_synced_page() {
1882 let (_tmp, _guard) = init_test_db();
1883 let chat = "channel_reply_batch";
1884 let author = make_test_npub(90_001);
1885
1886 let mut parent = Message::default();
1888 parent.id = "parent_evt".to_string();
1889 parent.content = "the original message".to_string();
1890 parent.npub = Some(author.clone());
1891 parent.at = now_secs() - 5_000;
1892 save_message(chat, &parent).await.unwrap();
1893
1894 let mut reply = Message::default();
1896 reply.id = "reply_evt".to_string();
1897 reply.content = "replying now".to_string();
1898 reply.replied_to = "parent_evt".to_string();
1899 reply.at = now_secs();
1900 let mut plain = Message::default();
1901 plain.id = "plain_evt".to_string();
1902 plain.content = "unrelated".to_string();
1903 plain.at = now_secs();
1904 let mut orphan = Message::default();
1906 orphan.id = "orphan_evt".to_string();
1907 orphan.replied_to = "never_seen_evt".to_string();
1908 orphan.at = now_secs();
1909
1910 populate_reply_contexts(vec![&mut reply, &mut plain, &mut orphan]).await.unwrap();
1911
1912 assert_eq!(
1913 reply.replied_to_content.as_deref(),
1914 Some("the original message"),
1915 "the synced reply carries its parent's content"
1916 );
1917 assert_eq!(reply.replied_to_npub.as_deref(), Some(author.as_str()), "and its parent's author");
1918 assert!(plain.replied_to_content.is_none(), "a non-reply is untouched");
1919 assert!(orphan.replied_to_content.is_none(), "an unresolvable parent leaves the quote empty");
1920 }
1921
1922 #[tokio::test]
1925 async fn batch_resolver_is_a_noop_without_replies() {
1926 let (_tmp, _guard) = init_test_db();
1927 populate_reply_contexts(vec![]).await.unwrap();
1928 let mut plain = Message::default();
1929 plain.id = "solo".to_string();
1930 populate_reply_contexts(vec![&mut plain]).await.unwrap();
1931 assert!(plain.replied_to_content.is_none());
1932 }
1933
1934 #[tokio::test]
1938 async fn system_event_stamps_authenticated_time_clamped_to_now() {
1939 let (_tmp, _guard) = init_test_db();
1940 let chat = "channel_ch2_timestamp";
1941 let before = now_secs();
1942 let past = before - 100_000;
1943
1944 save_system_event_at("ev_past", chat, SystemEventType::MemberJoined, "npubX", None, past, None, None).await.unwrap();
1945 save_system_event_at("ev_future", chat, SystemEventType::MemberJoined, "npubX", None, before + 100_000, None, None).await.unwrap();
1946 let after = now_secs();
1947
1948 let evs = get_system_events_for_chat(chat).unwrap();
1949 let past_ev = evs.iter().find(|e| e.id == "ev_past").expect("past event saved");
1950 assert_eq!(past_ev.created_at, past, "historical join keeps its real (authenticated) timestamp");
1951
1952 let fut_ev = evs.iter().find(|e| e.id == "ev_future").expect("future event saved");
1953 assert!(fut_ev.created_at >= before && fut_ev.created_at <= after,
1954 "future-dated event clamped to local now ({} not in {}..={})", fut_ev.created_at, before, after);
1955 }
1956
1957 #[tokio::test]
1960 async fn event_delete_context_resolves_from_db() {
1961 let (_tmp, _guard) = init_test_db();
1962 let chat = "npub1contactdc";
1963
1964 let mine_msg = Message { id: "dc_mine".into(), content: "x".into(), at: 1_000, mine: true, ..Default::default() };
1965 let theirs = Message {
1966 id: "dc_theirs".into(), content: "y".into(), at: 2_000, mine: false,
1967 npub: Some("npub1sender".to_string()),
1968 ..Default::default()
1969 };
1970 save_message(chat, &mine_msg).await.unwrap();
1971 save_message(chat, &theirs).await.unwrap();
1972
1973 let (chat_id, mine, _author) = event_delete_context("dc_mine").unwrap().expect("own row resolves");
1974 assert_eq!(chat_id, chat);
1975 assert!(mine);
1976
1977 let (chat_id, mine, author) = event_delete_context("dc_theirs").unwrap().expect("contact row resolves");
1978 assert_eq!(chat_id, chat);
1979 assert!(!mine);
1980 assert_eq!(author.as_deref(), Some("npub1sender"));
1981
1982 assert!(event_delete_context("dc_absent").unwrap().is_none(), "unknown id is None, not an error");
1983 }
1984
1985 #[tokio::test]
1988 async fn unread_counts_match_walk_back_semantics() {
1989 let (_tmp, _guard) = init_test_db();
1990 let chat = "npub1contactdm";
1991 let mk = |id: &str, secs: u64, mine: bool| Message {
1993 id: id.into(), content: "x".into(), at: secs * 1000, mine,
1994 npub: (!mine).then(|| "npub1sender".to_string()),
1995 ..Default::default()
1996 };
1997 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
1998
1999 for i in 0..6u64 {
2001 save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap();
2002 }
2003 assert_eq!(unread().await, 6, "never-read backlog counts all 6");
2004
2005 save_message(chat, &mk("m6", 2000, false)).await.unwrap();
2007 save_message(chat, &mk("m7", 2001, false)).await.unwrap();
2008 assert_eq!(unread().await, 8, "6 backlog + 2 new = 8");
2009
2010 save_message(chat, &mk("mine", 2002, true)).await.unwrap();
2012 assert_eq!(unread().await, 0, "own message = read up to here");
2013
2014 save_message(chat, &mk("m8", 2003, false)).await.unwrap();
2016 assert_eq!(unread().await, 1, "one new after our send");
2017
2018 {
2020 let conn = crate::db::get_write_connection_guard_static().unwrap();
2021 conn.execute(
2022 "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2023 rusqlite::params!["m8", chat],
2024 ).unwrap();
2025 }
2026 assert_eq!(unread().await, 0, "last_read=m8 clears all");
2027 save_message(chat, &mk("m9", 2004, false)).await.unwrap();
2028 assert_eq!(unread().await, 1, "one arrival after last_read");
2029 }
2030
2031 #[tokio::test]
2034 async fn unread_counts_exclude_muted_and_blocked_senders() {
2035 let (_tmp, _guard) = init_test_db();
2036 let chat = "channel_mute_exclusion";
2037 let mk = |id: &str, secs: u64, npub: &str| Message {
2038 id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2039 npub: Some(npub.to_string()),
2040 ..Default::default()
2041 };
2042 save_message(chat, &mk("n1", 1000, "npub1noisy")).await.unwrap();
2043 save_message(chat, &mk("q1", 1001, "npub1quiet")).await.unwrap();
2044
2045 let map = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2046 let one = || async { unread_count_for_chat(chat).await.unwrap() };
2047 assert_eq!(map().await, 2, "both senders count while neither is muted");
2048 assert_eq!(one().await, 2);
2049
2050 {
2052 let conn = crate::db::get_write_connection_guard_static().unwrap();
2053 conn.execute(
2054 "INSERT INTO chats (chat_identifier, chat_type, participants, created_at, muted) \
2055 VALUES ('npub1noisy', 0, '', 1000, 1)",
2056 [],
2057 ).unwrap();
2058 }
2059 assert_eq!(map().await, 1, "muted sender's message stops counting");
2060 assert_eq!(one().await, 1);
2061
2062 {
2064 let conn = crate::db::get_write_connection_guard_static().unwrap();
2065 conn.execute(
2066 "INSERT INTO profiles (npub, is_blocked) VALUES ('npub1quiet', 1) \
2067 ON CONFLICT(npub) DO UPDATE SET is_blocked = 1",
2068 [],
2069 ).unwrap();
2070 }
2071 assert_eq!(map().await, 0, "blocked sender's message stops counting too");
2072 assert_eq!(one().await, 0);
2073
2074 {
2076 let conn = crate::db::get_write_connection_guard_static().unwrap();
2077 conn.execute("UPDATE chats SET muted = 0 WHERE chat_identifier = 'npub1noisy'", []).unwrap();
2078 }
2079 assert_eq!(map().await, 1, "unmuting restores the sender's count");
2080 assert_eq!(one().await, 1);
2081 }
2082
2083 #[tokio::test]
2086 async fn unread_count_for_chat_matches_the_map() {
2087 let (_tmp, _guard) = init_test_db();
2088 let chat = "npub1reconcile";
2089 let mk = |id: &str, secs: u64, mine: bool| Message {
2090 id: id.into(), content: "x".into(), at: secs * 1000, mine,
2091 npub: (!mine).then(|| "npub1sender".to_string()),
2092 ..Default::default()
2093 };
2094 let agree = || async {
2095 let map = unread_counts().await.unwrap().get(chat).copied().unwrap_or(0);
2096 let one = unread_count_for_chat(chat).await.unwrap();
2097 assert_eq!(map, one, "single-chat query diverged from the map");
2098 one
2099 };
2100
2101 for i in 0..4u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap(); }
2102 assert_eq!(agree().await, 4);
2103 save_message(chat, &mk("mine", 1010, true)).await.unwrap();
2104 assert_eq!(agree().await, 0);
2105 save_message(chat, &mk("after", 1011, false)).await.unwrap();
2106 assert_eq!(agree().await, 1);
2107 {
2108 let conn = crate::db::get_write_connection_guard_static().unwrap();
2109 conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2110 rusqlite::params!["after", chat]).unwrap();
2111 }
2112 assert_eq!(agree().await, 0);
2113 assert_eq!(unread_count_for_chat("npub1nonexistent").await.unwrap(), 0);
2115 }
2116
2117 #[tokio::test]
2118 async fn attachments_table_round_trip_dedup_and_cascade() {
2119 let (_tmp, _guard) = init_test_db();
2120 let att = |id: &str, name: &str| Attachment {
2122 id: id.into(), url: format!("https://blossom/{id}"), name: name.into(),
2123 extension: "png".into(), size: 42, downloaded: false, ..Default::default()
2124 };
2125 let msg = |mid: &str, secs: u64, atts: Vec<Attachment>| Message {
2126 id: mid.into(), content: String::new(), at: secs * 1000, mine: false,
2127 npub: Some("npub1sender".into()), attachments: atts, ..Default::default()
2128 };
2129
2130 save_message("npub1att", &msg("m1", 1000, vec![att("hashA", "a.png"), att("hashB", "b.png")])).await.unwrap();
2132 let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2133 assert_eq!(got.len(), 2);
2134 assert_eq!((got[0].id.as_str(), got[1].id.as_str()), ("hashA", "hashB"), "att_index order");
2135 assert_eq!(got[0].name, "a.png");
2136 assert_eq!(got[0].size, 42);
2137 assert!(!got[0].downloaded);
2138
2139 crate::db::attachments::set_attachment_downloaded("m1", "hashA", true, "/tmp/a.png").unwrap();
2141 let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2142 assert!(got[0].downloaded && got[0].path == "/tmp/a.png");
2143 assert!(!got[1].downloaded, "sibling attachment untouched");
2144
2145 save_message("npub1att", &msg("m2", 1001, vec![att("hashA", "a-again.png")])).await.unwrap();
2147 let affected = crate::db::attachments::backfill_downloaded_by_hash("hashA", "/tmp/a.png", "m1").unwrap();
2148 assert_eq!(affected, vec!["m2".to_string()]);
2149 assert!(crate::db::attachments::get_attachments_for_event("m2").unwrap()[0].downloaded);
2150
2151 let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1att").unwrap();
2154 let views = get_message_views(chat_int, 10, 0).await.unwrap();
2155 let m1 = views.iter().find(|m| m.id == "m1").unwrap();
2156 assert_eq!(m1.attachments.len(), 2);
2157
2158 delete_event("m1").await.unwrap();
2160 assert!(crate::db::attachments::get_attachments_for_event("m1").unwrap().is_empty(), "ON DELETE CASCADE");
2161 }
2162
2163 #[tokio::test]
2166 async fn attachment_download_state_is_monotonic_across_resaves() {
2167 let (_tmp, _guard) = init_test_db();
2168 let att = |id: &str, downloaded: bool, path: &str| Attachment {
2169 id: id.into(), url: "u".into(), name: "f.png".into(), extension: "png".into(),
2170 size: 1, downloaded, path: path.into(), ..Default::default()
2171 };
2172 let msg = |atts: Vec<Attachment>| Message {
2173 id: "dl1".into(), content: String::new(), at: 1_000_000, mine: false,
2174 npub: Some("npub1s".into()), attachments: atts, ..Default::default()
2175 };
2176
2177 save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2179 crate::db::attachments::set_attachment_downloaded("dl1", "nonceid", true, "/tmp/f.png").unwrap();
2180
2181 save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2183 let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2184 assert!(got[0].downloaded && got[0].path == "/tmp/f.png", "re-delivery preserves the download");
2185
2186 save_message("npub1dl", &msg(vec![att("contenthash", true, "/tmp/f.png")])).await.unwrap();
2188 let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2189 assert_eq!(got[0].id, "contenthash", "hash rewritten nonce→content");
2190 assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2191
2192 save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2195 let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2196 assert_eq!(got[0].id, "contenthash", "content-hash key survives a later re-delivery");
2197 assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2198 }
2199
2200 #[tokio::test]
2203 async fn attachments_fall_back_to_legacy_tag_when_table_empty() {
2204 let (_tmp, _guard) = init_test_db();
2205 let a = Attachment {
2206 id: "tagonly".into(), url: "u".into(), name: "old.png".into(), extension: "png".into(),
2207 size: 7, downloaded: false, ..Default::default()
2208 };
2209 save_message("npub1old", &Message {
2210 id: "old1".into(), content: String::new(), at: 2_000_000, mine: false,
2211 npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2212 }).await.unwrap();
2213
2214 {
2216 let conn = crate::db::get_write_connection_guard_static().unwrap();
2217 conn.execute("DELETE FROM attachments WHERE event_id='old1'", []).unwrap();
2218 let inner = serde_json::to_string(&vec![a]).unwrap();
2219 let tags = serde_json::to_string(&vec![vec!["attachments".to_string(), inner]]).unwrap();
2220 conn.execute("UPDATE events SET tags=?1 WHERE id='old1'", rusqlite::params![tags]).unwrap();
2221 }
2222 assert!(crate::db::attachments::get_attachments_for_event("old1").unwrap().is_empty(), "table empty");
2223
2224 let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1old").unwrap();
2225 let views = get_message_views(chat_int, 10, 0).await.unwrap();
2226 let old = views.iter().find(|m| m.id == "old1").unwrap();
2227 assert_eq!(old.attachments.len(), 1, "attachment served from the legacy-tag fallback");
2228 assert_eq!(old.attachments[0].name, "old.png");
2229 }
2230
2231 #[tokio::test]
2234 async fn attachment_tag_strip_is_gated_on_backfill() {
2235 let (_tmp, _guard) = init_test_db();
2236 let a = Attachment { id: "h1".into(), extension: "png".into(), name: "f.png".into(), downloaded: false, ..Default::default() };
2237 let mk = |id: &str, secs: u64| Message {
2238 id: id.into(), content: String::new(), at: secs * 1000, mine: false,
2239 npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2240 };
2241 save_message("npub1s", &mk("bf", 1000)).await.unwrap();
2242 save_message("npub1s", &mk("unbf", 2000)).await.unwrap();
2243
2244 {
2245 let conn = crate::db::get_write_connection_guard_static().unwrap();
2246 let inner = serde_json::to_string(&vec![a.clone()]).unwrap();
2247 let with_tag = |ms: &str| serde_json::to_string(&vec![
2248 vec!["ms".to_string(), ms.to_string()],
2249 vec!["attachments".to_string(), inner.clone()],
2250 ]).unwrap();
2251 conn.execute("UPDATE events SET tags=?1 WHERE id='bf'", rusqlite::params![with_tag("5")]).unwrap();
2253 conn.execute("UPDATE events SET tags=?1 WHERE id='unbf'", rusqlite::params![with_tag("6")]).unwrap();
2254 conn.execute("DELETE FROM attachments WHERE event_id='unbf'", []).unwrap();
2255
2256 let events: Vec<(String, String)> = {
2258 let mut stmt = conn.prepare("SELECT id, tags FROM events WHERE tags LIKE '%attachments%' AND id IN (SELECT DISTINCT event_id FROM attachments)").unwrap();
2259 let m = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).unwrap();
2260 m.flatten().collect()
2261 };
2262 for (id, tj) in events {
2263 let mut tags: Vec<Vec<String>> = serde_json::from_str(&tj).unwrap();
2264 tags.retain(|t| t.first().map(|s| s.as_str()) != Some("attachments"));
2265 conn.execute("UPDATE events SET tags=?1 WHERE id=?2",
2266 rusqlite::params![serde_json::to_string(&tags).unwrap(), id]).unwrap();
2267 }
2268
2269 let bf: String = conn.query_row("SELECT tags FROM events WHERE id='bf'", [], |r| r.get(0)).unwrap();
2270 assert!(!bf.contains("attachments"), "backfilled event's attachments tag stripped");
2271 assert!(bf.contains("\"ms\""), "sibling ms tag survives the strip");
2272 let unbf: String = conn.query_row("SELECT tags FROM events WHERE id='unbf'", [], |r| r.get(0)).unwrap();
2273 assert!(unbf.contains("attachments"), "un-backfilled event keeps its tag (no table row)");
2274 }
2275
2276 let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1s").unwrap();
2278 let views = get_message_views(chat_int, 10, 0).await.unwrap();
2279 assert_eq!(views.iter().find(|m| m.id == "unbf").unwrap().attachments.len(), 1, "fallback still renders unbf");
2280 }
2281
2282 #[tokio::test]
2286 async fn compute_unread_anchor_covers_the_cases() {
2287 let (_tmp, _guard) = init_test_db();
2288 let mk = |id: &str, secs: u64, mine: bool| Message {
2289 id: id.into(), content: "x".into(), at: secs * 1000, mine,
2290 npub: (!mine).then(|| "npub1sender".to_string()),
2291 ..Default::default()
2292 };
2293 let unread = |chat: &'static str| async move {
2294 unread_counts().await.unwrap().get(chat).copied().unwrap_or(0)
2295 };
2296 let set_lr = |chat: &str, lr: &str| {
2297 let conn = crate::db::get_write_connection_guard_static().unwrap();
2298 conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2299 rusqlite::params![lr, chat]).unwrap();
2300 };
2301
2302 let a = "npub1anchorA";
2305 save_message(a, &mk("a_mine", 1000, true)).await.unwrap();
2306 for i in 0..8u64 { save_message(a, &mk(&format!("a{i}"), 2000 + i, false)).await.unwrap(); }
2307 assert_eq!(compute_unread_anchor(a).await.unwrap(), UnreadMark::Anchor("a6".into()));
2308 set_lr(a, "a6");
2309 assert_eq!(unread(a).await, 1, "A: newest contact message is the sole unread");
2310
2311 let b = "npub1anchorB";
2313 save_message(b, &mk("b0", 2000, false)).await.unwrap();
2314 save_message(b, &mk("b_mine", 2001, true)).await.unwrap();
2315 assert_eq!(compute_unread_anchor(b).await.unwrap(), UnreadMark::NoOp);
2316
2317 let c = "npub1anchorC";
2320 save_message(c, &mk("c0", 3000, false)).await.unwrap();
2321 save_message(c, &mk("c1", 3005, false)).await.unwrap();
2322 save_message(c, &mk("c2", 3005, false)).await.unwrap();
2323 assert_eq!(compute_unread_anchor(c).await.unwrap(), UnreadMark::Anchor("c0".into()));
2324 set_lr(c, "c0");
2325 assert_eq!(unread(c).await, 2, "C: same-second tail both count");
2326
2327 let d = "npub1anchorD";
2329 save_message(d, &mk("d0", 4000, false)).await.unwrap();
2330 assert_eq!(compute_unread_anchor(d).await.unwrap(), UnreadMark::Clear);
2331 set_lr(d, "");
2332 assert_eq!(unread(d).await, 1, "D: lone contact message surfaces");
2333
2334 let e = "npub1anchorE";
2336 save_message(e, &mk("e_mine", 5000, true)).await.unwrap();
2337 assert_eq!(compute_unread_anchor(e).await.unwrap(), UnreadMark::NoOp);
2338 }
2339
2340 #[tokio::test]
2348 async fn a_system_event_with_a_blank_conversation_id_is_refused() {
2349 let (_tmp, _guard) = init_test_db();
2350 let before = chat_row_count();
2351 for blank in ["", " "] {
2352 assert!(
2353 save_system_event_at("ev", blank, SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2354 .await
2355 .is_err(),
2356 "a blank conversation id must be refused, not minted"
2357 );
2358 }
2359 assert_eq!(chat_row_count(), before, "and no chat row is created");
2360 assert!(
2362 save_system_event_at("ev2", "real-chat", SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2363 .await
2364 .unwrap()
2365 );
2366 }
2367
2368 fn chat_row_count() -> i64 {
2369 let conn = crate::db::get_db_connection_guard_static().unwrap();
2370 conn.query_row("SELECT count(*) FROM chats", [], |r| r.get(0)).unwrap()
2371 }
2372
2373 #[tokio::test]
2374 async fn unread_clears_when_last_read_is_a_system_event() {
2375 let (_tmp, _guard) = init_test_db();
2376 let chat = "npub1sysevtdm";
2377 let mk = |id: &str, secs: u64| Message {
2378 id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2379 npub: Some("npub1sender".to_string()), ..Default::default()
2380 };
2381 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2382
2383 for i in 0..5u64 {
2386 save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap();
2387 }
2388 assert_eq!(unread().await, 5, "never-read backlog");
2389
2390 save_system_event_at("sysev", chat, SystemEventType::MemberJoined, "npubX", None, 2000, None, None).await.unwrap();
2392
2393 {
2395 let conn = crate::db::get_write_connection_guard_static().unwrap();
2396 conn.execute(
2397 "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2398 rusqlite::params!["sysev", chat],
2399 ).unwrap();
2400 }
2401 assert_eq!(unread().await, 0, "read marker on a system event still clears the badge");
2402 }
2403
2404 #[tokio::test]
2408 async fn deleting_a_message_adjusts_unread_without_wedging() {
2409 let (_tmp, _guard) = init_test_db();
2410 let chat = "npub1delunread";
2411 let mk = |id: &str, secs: u64| Message {
2412 id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2413 npub: Some("npub1sender".to_string()), ..Default::default()
2414 };
2415 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2416 let set_marker = |id: &str| {
2417 let conn = crate::db::get_write_connection_guard_static().unwrap();
2418 conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2419 rusqlite::params![id, chat]).unwrap();
2420 };
2421 let marker = || -> String {
2422 let conn = crate::db::get_db_connection_guard_static().unwrap();
2423 conn.query_row("SELECT last_read FROM chats WHERE chat_identifier = ?1",
2424 rusqlite::params![chat], |r| r.get::<_, String>(0)).unwrap()
2425 };
2426
2427 for i in 0..6u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap(); }
2429 set_marker("m1");
2430 assert_eq!(unread().await, 4, "m2..m5 unread");
2431
2432 delete_event("m3").await.unwrap();
2434 assert_eq!(unread().await, 3, "one unread deleted → badge minus one");
2435 assert_eq!(marker(), "m1", "deleting an unread message leaves the marker alone");
2436
2437 delete_event("m1").await.unwrap();
2439 assert_eq!(marker(), "m0", "marker retreats to the newest survivor before it");
2440 assert_eq!(unread().await, 3, "retreat keeps the count, no collapse to 99+");
2441
2442 delete_event("m0").await.unwrap();
2444 assert_eq!(marker(), "", "no earlier survivor → marker clears");
2445 assert_eq!(unread().await, 3, "cleared marker counts only the true unread survivors");
2446 }
2447
2448 #[tokio::test]
2452 async fn edit_event_folds_into_history_on_reload() {
2453 let (_tmp, _guard) = init_test_db();
2454 let chat = "channel_edit_fold";
2455 save_message(chat, &Message {
2456 id: "orig1".into(), content: "original".into(), at: 5_000_000,
2457 npub: Some("npub1author".into()), ..Default::default()
2458 }).await.unwrap();
2459
2460 let cid = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2461 let emoji = vec![crate::types::EmojiTag { shortcode: "wave".into(), url: "u/wave".into() }];
2462 save_edit_event("edit1", "orig1", "edited :wave:", &emoji, cid, None, "npub1author").await.unwrap();
2463
2464 let m = get_message_views(cid, 50, 0).await.unwrap()
2465 .into_iter().find(|m| m.id == "orig1").expect("message reloaded");
2466 assert!(m.edited, "folded edit sets the edited flag");
2467 let h = m.edit_history.as_ref().expect("history reconstructed from the edit event");
2468 assert_eq!(h.len(), 2, "original + one edit");
2469 assert_eq!(h[0].content, "original");
2470 assert_eq!(h[1].content, "edited :wave:");
2471 assert_eq!(m.content, "edited :wave:", "latest revision is the displayed content");
2472 assert_eq!(m.emoji_tags.len(), 1, "the edit's own emoji folds onto the message");
2473 assert_eq!(m.emoji_tags[0].shortcode, "wave");
2474 }
2475
2476 #[tokio::test]
2480 async fn batched_save_matches_single_save_shape() {
2481 let (_tmp, _guard) = init_test_db();
2482 let chat = "channel_batch1";
2483 let att = crate::types::Attachment {
2484 id: "atthash1".into(), extension: "png".into(), name: "a.png".into(),
2485 url: "https://x/att".into(), downloaded: false, ..Default::default()
2486 };
2487 let reaction = Reaction {
2488 id: "react_b1".into(), reference_id: "b1".into(),
2489 author_id: "npub1reactor".into(), emoji: "👍".into(), emoji_url: None,
2490 };
2491 let msgs: Vec<Message> = (0..5u64).map(|i| Message {
2493 id: format!("b{i}"), content: format!("c{i}"), at: 7_000_000,
2494 npub: Some("npub1sender".into()),
2495 attachments: if i == 2 { vec![att.clone()] } else { Vec::new() },
2496 reactions: if i == 1 { vec![reaction.clone()] } else { Vec::new() },
2497 ..Default::default()
2498 }).collect();
2499 let refs: Vec<&Message> = msgs.iter().collect();
2500
2501 let saved = save_messages_batch(chat, &refs).await.unwrap();
2502 assert_eq!(saved, 5, "every message written");
2503
2504 for i in 0..5u64 {
2505 assert!(event_exists(&format!("b{i}")).unwrap(), "b{i} row exists");
2506 }
2507 assert!(event_exists("react_b1").unwrap(), "reaction landed as its own kind-7 row");
2508 let atts = crate::db::attachments::get_attachments_for_event("b2").unwrap();
2509 assert_eq!(atts.len(), 1, "attachment row committed with its event");
2510 assert_eq!(atts[0].id, "atthash1");
2511
2512 let conn = crate::db::get_db_connection_guard_static().unwrap();
2514 let ids: Vec<String> = conn
2515 .prepare("SELECT id FROM events WHERE id IN ('b0','b1','b2','b3','b4') ORDER BY rowid")
2516 .unwrap()
2517 .query_map([], |r| r.get(0)).unwrap()
2518 .flatten().collect();
2519 assert_eq!(ids, vec!["b0", "b1", "b2", "b3", "b4"], "insert order preserves the rowid tiebreak");
2520 }
2521
2522 #[tokio::test]
2525 async fn batched_resave_preserves_wrapper_and_dedups_reactions() {
2526 let (_tmp, _guard) = init_test_db();
2527 let chat = "channel_batch2";
2528 let reaction = Reaction {
2529 id: "react_rs".into(), reference_id: "rs1".into(),
2530 author_id: "npub1reactor".into(), emoji: "🔥".into(), emoji_url: None,
2531 };
2532 let mut msg = Message {
2533 id: "rs1".into(), content: "hello".into(), at: 8_000_000,
2534 npub: Some("npub1sender".into()),
2535 wrapper_event_id: Some("wrap_original".into()),
2536 reactions: vec![reaction],
2537 ..Default::default()
2538 };
2539 save_message(chat, &msg).await.unwrap();
2540
2541 msg.wrapper_event_id = None;
2543 let saved = save_messages_batch(chat, &[&msg]).await.unwrap();
2544 assert_eq!(saved, 1);
2545
2546 let conn = crate::db::get_db_connection_guard_static().unwrap();
2547 let wrapper: Option<String> = conn.query_row(
2548 "SELECT wrapper_event_id FROM events WHERE id = 'rs1'", [], |r| r.get(0),
2549 ).unwrap();
2550 assert_eq!(wrapper.as_deref(), Some("wrap_original"), "COALESCE keeps the known wrapper");
2551 let reaction_rows: i64 = conn.query_row(
2552 "SELECT COUNT(*) FROM events WHERE id = 'react_rs'", [], |r| r.get(0),
2553 ).unwrap();
2554 assert_eq!(reaction_rows, 1, "reaction row not duplicated by the re-save");
2555 }
2556
2557 #[tokio::test]
2561 async fn batching_persist_flushes_multi_chat_and_drains_into_the_account_that_filled_it() {
2562 let (_tmp, _guard) = init_test_db();
2563 let handler = crate::event_handler::NoOpEventHandler;
2564 let batcher = crate::event_handler::BatchingPersist::new(&handler);
2565
2566 let mk = |id: &str, npub: &str| Message {
2567 id: id.into(), content: "x".into(), at: 9_000_000,
2568 npub: Some(npub.into()), ..Default::default()
2569 };
2570 let seed = |chat: &str, m: &Message| {
2573 let m = m.clone();
2574 let chat = chat.to_string();
2575 async move {
2576 let mut st = crate::state::STATE.lock().await;
2577 st.add_message_to_participant(&chat, &m);
2578 }
2579 };
2580 let wrap_a1 = ([0xA1u8; 32], 111u64);
2581 let wrap_b1 = ([0xB1u8; 32], 222u64);
2582 let a1 = mk("bp_a1", "npub1chata");
2583 let b1 = mk("bp_b1", "npub1chatb");
2584 let a2 = mk("bp_a2", "npub1chata");
2585 seed("npub1chata", &a1).await;
2586 seed("npub1chatb", &b1).await;
2587 seed("npub1chata", &a2).await;
2588
2589 use crate::event_handler::InboundEventHandler;
2590 assert!(batcher.buffer_persist("npub1chata", &a1, Some(wrap_a1)), "batcher owns the persist");
2591 assert!(batcher.buffer_persist("npub1chatb", &b1, Some(wrap_b1)));
2592 assert!(batcher.buffer_persist("npub1chata", &a2, None));
2593 assert_eq!(batcher.buffered(), 3);
2594
2595 let ledgered = |bytes: [u8; 32]| {
2596 let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2597 crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
2598 };
2599 assert!(!ledgered(wrap_a1.0), "wrapper unledgered while its message sits buffered");
2600
2601 assert_eq!(batcher.flush().await, 3, "all buffered messages written");
2602 assert_eq!(batcher.buffered(), 0);
2603 assert!(event_exists("bp_a1").unwrap() && event_exists("bp_b1").unwrap() && event_exists("bp_a2").unwrap());
2604 assert!(ledgered(wrap_a1.0) && ledgered(wrap_b1.0), "wrappers ledgered with the flush");
2605 let a = crate::db::id_cache::get_chat_id_by_identifier("npub1chata").unwrap();
2606 let b = crate::db::id_cache::get_chat_id_by_identifier("npub1chatb").unwrap();
2607 assert_ne!(a, b, "rows grouped under their own chats");
2608
2609 let filled_under = crate::db::current_session();
2613 let late = mk("bp_late", "npub1chata");
2614 seed("npub1chata", &late).await;
2615 let wrap_late = ([0x5Eu8; 32], 333u64);
2616 batcher.buffer_persist("npub1chata", &late, Some(wrap_late));
2617
2618 let next = make_test_npub(TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
2619 std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&next)).unwrap();
2620 crate::db::set_current_account(next.clone()).unwrap();
2621 crate::db::init_database(&next).unwrap();
2622
2623 assert_eq!(batcher.flush().await, 1, "the buffer still drains");
2624 assert!(
2625 crate::db::with_session(filled_under, async {
2626 event_exists("bp_late").unwrap() && ledgered(wrap_late.0)
2627 })
2628 .await,
2629 "written to the account that received it, and its wrapper ledgered there"
2630 );
2631 assert!(!event_exists("bp_late").unwrap(), "the account swapped in never sees it");
2632 assert_eq!(batcher.buffered(), 0, "and the buffer is empty either way");
2633 }
2634
2635 #[tokio::test]
2642 async fn a_delete_tombstone_survives_a_restart() {
2643 let (_tmp, _guard) = init_test_db();
2647 let msg = Message {
2648 id: "resurrect-me".into(), content: "note".into(), at: 1_000_000,
2649 mine: true, ..Default::default()
2650 };
2651 save_message("npub1notes", &msg).await.unwrap();
2652 add_message_tombstone("resurrect-me").unwrap();
2653 delete_event("resurrect-me").await.unwrap();
2654
2655 crate::db::close_database();
2657 assert!(!crate::state::was_message_deleted("resurrect-me"), "session set cleared");
2658 crate::state::seed_message_tombstones(load_message_tombstones().unwrap());
2660 assert!(crate::state::was_message_deleted("resurrect-me"), "the durable tombstone re-seeds");
2661 }
2662
2663 #[tokio::test]
2664 async fn buffered_message_deleted_before_flush_never_persists() {
2665 let (_tmp, _guard) = init_test_db();
2666 let handler = crate::event_handler::NoOpEventHandler;
2667 let batcher = crate::event_handler::BatchingPersist::new(&handler);
2668 use crate::event_handler::InboundEventHandler;
2669
2670 let chat = "npub1delchat";
2671 let mk = |id: &str| Message {
2672 id: id.into(), content: "x".into(), at: 9_500_000,
2673 npub: Some(chat.into()), ..Default::default()
2674 };
2675 let ledgered = |bytes: [u8; 32]| {
2676 let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2677 crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
2678 };
2679
2680 let m1 = mk("del_sametask");
2683 {
2684 let mut st = crate::state::STATE.lock().await;
2685 st.add_message_to_participant(chat, &m1);
2686 }
2687 batcher.buffer_persist(chat, &m1, Some(([0xD1u8; 32], 444)));
2688 {
2689 let mut st = crate::state::STATE.lock().await;
2690 st.remove_message("del_sametask");
2691 }
2692 batcher.on_message_deleted(chat, "del_sametask");
2693 assert_eq!(batcher.buffered(), 0, "deletion purges the buffered target");
2694
2695 let m2 = mk("del_crosstask");
2698 {
2699 let mut st = crate::state::STATE.lock().await;
2700 st.add_message_to_participant(chat, &m2);
2701 }
2702 batcher.buffer_persist(chat, &m2, Some(([0xD2u8; 32], 555)));
2703 crate::state::note_message_deleted("del_crosstask");
2704
2705 let m3 = mk("evicted_ok");
2707 let wrap_evicted = ([0xE0u8; 32], 666u64);
2708 {
2709 let mut st = crate::state::STATE.lock().await;
2710 st.add_message_to_participant(chat, &m3);
2711 st.remove_message("evicted_ok");
2712 }
2713 batcher.buffer_persist(chat, &m3, Some(wrap_evicted));
2714
2715 assert_eq!(batcher.flush().await, 1, "tombstoned target dropped, evicted message written");
2716 assert!(!event_exists("del_sametask").unwrap(), "purged message never persisted");
2717 assert!(!event_exists("del_crosstask").unwrap(), "tombstoned message never persisted");
2718 assert!(event_exists("evicted_ok").unwrap(), "evicted-but-not-deleted message persisted");
2719 assert!(ledgered(wrap_evicted.0), "evicted message's wrapper ledgered with it");
2720 }
2721}
2722
2723pub fn get_event_wrap_context(event_id: &str) -> Result<Option<(Option<String>, Vec<Vec<String>>)>, String> {
2726 let conn = super::get_db_connection_guard_static()?;
2727 let row: Option<(Option<String>, String)> = conn
2728 .query_row(
2729 "SELECT wrapper_event_id, tags FROM events WHERE id = ?1",
2730 rusqlite::params![event_id],
2731 |r| Ok((r.get(0)?, r.get(1)?)),
2732 )
2733 .optional()
2734 .map_err(|e| format!("get wrap context: {e}"))?;
2735 Ok(row.map(|(wrap, tags_json)| {
2736 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
2737 (wrap, tags)
2738 }))
2739}