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> {
759 let conn = super::get_db_connection_guard_static()?;
760 let chat_id: i64 = conn.query_row(
761 "SELECT id FROM chats WHERE chat_identifier = ?1",
762 rusqlite::params![conversation_id], |row| row.get(0)
763 ).map_err(|_| "Chat not found")?;
764
765 let mut stmt = conn.prepare(
766 &format!("SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
767 created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
768 FROM events WHERE chat_id = ?1 AND kind = ?2{} ORDER BY created_at ASC, received_at ASC",
769 ban_filter_sql(3))
770 ).map_err(|e| format!("Failed to prepare: {}", e))?;
771
772 let rows = stmt.query_map(
773 rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32, community_of_chat(&conn, chat_id)],
774 |row| {
775 let tags_json: String = row.get(5)?;
776 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
777 Ok(StoredEvent {
778 id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
779 chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
780 tags, reference_id: row.get(6)?,
781 created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
782 mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
783 failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
784 npub: row.get(13)?, preview_metadata: None,
785 })
786 }
787 ).map_err(|e| format!("Failed to query: {}", e))?;
788
789 let mut events = Vec::new();
790 for row in rows {
791 let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
792 if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "system-event") {
793 events.push(event);
794 }
795 }
796 Ok(events)
797}
798
799fn parse_event_row(row: &rusqlite::Row) -> rusqlite::Result<StoredEvent> {
805 let tags_json: String = row.get(5)?;
806 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
807
808 Ok(StoredEvent {
809 id: row.get(0)?,
810 kind: row.get::<_, i32>(1)? as u16,
811 chat_id: row.get(2)?,
812 user_id: row.get(3)?,
813 content: row.get(4)?,
814 tags,
815 reference_id: row.get(6)?,
816 created_at: row.get::<_, i64>(7)? as u64,
817 received_at: row.get::<_, i64>(8)? as u64,
818 mine: row.get::<_, i32>(9)? != 0,
819 pending: row.get::<_, i32>(10)? != 0,
820 failed: row.get::<_, i32>(11)? != 0,
821 wrapper_event_id: row.get(12)?,
822 npub: row.get(13)?,
823 preview_metadata: row.get(14)?,
824 })
825}
826
827fn community_of_chat(conn: &rusqlite::Connection, chat_id: i64) -> Option<String> {
833 conn.query_row(
834 "SELECT cc.community_id FROM community_channels cc \
835 JOIN chats c ON c.chat_identifier = cc.channel_id WHERE c.id = ?1",
836 rusqlite::params![chat_id],
837 |r| r.get::<_, String>(0),
838 )
839 .ok()
840}
841
842fn ban_filter_sql(param: usize) -> String {
851 format!(
852 " AND (?{param} IS NULL OR events.npub IS NULL OR NOT EXISTS ( \
853 SELECT 1 FROM community_bans b \
854 WHERE b.community_id = ?{param} AND b.npub = events.npub))"
855 )
856}
857
858pub async fn get_events(
861 chat_id: i64,
862 kinds: Option<&[u16]>,
863 limit: usize,
864 offset: usize,
865) -> Result<Vec<StoredEvent>, String> {
866 let events: Vec<StoredEvent> = {
867 let conn = super::get_db_connection_guard_static()?;
868 let community = community_of_chat(&conn, chat_id);
869
870 if let Some(k) = kinds {
871 let kind_placeholders: String = (0..k.len())
872 .map(|i| format!("?{}", i + 2))
873 .collect::<Vec<_>>()
874 .join(",");
875 let limit_param = k.len() + 2;
876 let offset_param = k.len() + 3;
877 let community_param = k.len() + 4;
878
879 let sql = format!(
880 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
881 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
882 FROM events WHERE chat_id = ?1 AND kind IN ({}){} \
883 ORDER BY created_at DESC, received_at DESC \
884 LIMIT ?{} OFFSET ?{}",
885 kind_placeholders, ban_filter_sql(community_param), limit_param, offset_param
886 );
887
888 let mut stmt = conn.prepare(&sql)
889 .map_err(|e| format!("Failed to prepare events query: {}", e))?;
890
891 match k.len() {
892 1 => {
893 let rows = stmt.query_map(
894 rusqlite::params![chat_id, k[0] as i32, limit as i64, offset as i64, community],
895 parse_event_row
896 ).map_err(|e| format!("Failed to query events: {}", e))?;
897 rows.filter_map(|r| r.ok()).collect()
898 },
899 2 => {
900 let rows = stmt.query_map(
901 rusqlite::params![chat_id, k[0] as i32, k[1] as i32, limit as i64, offset as i64, community],
902 parse_event_row
903 ).map_err(|e| format!("Failed to query events: {}", e))?;
904 rows.filter_map(|r| r.ok()).collect()
905 },
906 3 => {
907 let rows = stmt.query_map(
908 rusqlite::params![chat_id, k[0] as i32, k[1] as i32, k[2] as i32, limit as i64, offset as i64, community],
909 parse_event_row
910 ).map_err(|e| format!("Failed to query events: {}", e))?;
911 rows.filter_map(|r| r.ok()).collect()
912 },
913 _ => return Err("Unsupported number of kinds".to_string()),
914 }
915 } else {
916 let mut stmt = conn.prepare(
917 &format!("SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
918 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
919 FROM events WHERE chat_id = ?1{} \
920 ORDER BY created_at DESC, received_at DESC \
921 LIMIT ?2 OFFSET ?3", ban_filter_sql(4))
922 ).map_err(|e| format!("Failed to prepare events query: {}", e))?;
923
924 let rows = stmt.query_map(
925 rusqlite::params![chat_id, limit as i64, offset as i64, community],
926 parse_event_row
927 ).map_err(|e| format!("Failed to query events: {}", e))?;
928 rows.filter_map(|r| r.ok()).collect()
929 }
930 };
931
932 let mut decrypted = Vec::with_capacity(events.len());
934 for mut event in events {
935 if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
936 event.content = crate::crypto::maybe_decrypt(event.content).await
937 .unwrap_or_else(|_| "[Decryption failed]".to_string());
938 }
939 decrypted.push(event);
940 }
941
942 Ok(decrypted)
943}
944
945pub async fn get_related_events(
947 reference_ids: &[String],
948) -> Result<Vec<StoredEvent>, String> {
949 if reference_ids.is_empty() {
950 return Ok(Vec::new());
951 }
952
953 let conn = super::get_db_connection_guard_static()?;
954
955 let placeholders: String = reference_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
956 let sql = format!(
957 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
958 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
959 FROM events WHERE reference_id IN ({}) \
960 ORDER BY created_at ASC, received_at ASC",
961 placeholders
962 );
963
964 let mut stmt = conn.prepare(&sql)
965 .map_err(|e| format!("Failed to prepare related events query: {}", e))?;
966
967 let params: Vec<&dyn rusqlite::ToSql> = reference_ids.iter()
968 .map(|s| s as &dyn rusqlite::ToSql)
969 .collect();
970
971 let events: Vec<StoredEvent> = stmt.query_map(params.as_slice(), parse_event_row)
972 .map_err(|e| format!("Failed to query related events: {}", e))?
973 .filter_map(|r| r.ok())
974 .collect();
975
976 Ok(events)
977}
978
979pub struct ReplyContext {
981 pub content: String,
982 pub npub: Option<String>,
983 pub has_attachment: bool,
984 pub extension: Option<String>,
987 pub emoji_tags: Vec<crate::types::EmojiTag>,
990}
991
992pub async fn get_reply_contexts(
994 message_ids: &[String],
995) -> Result<std::collections::HashMap<String, ReplyContext>, String> {
996 use std::collections::HashMap;
997
998 if message_ids.is_empty() {
999 return Ok(HashMap::new());
1000 }
1001
1002 let (events, edits): (Vec<(String, i32, String, Option<String>, Option<String>)>, Vec<(String, String, Option<String>)>) = {
1003 let conn = super::get_db_connection_guard_static()?;
1004
1005 let placeholders: String = (0..message_ids.len())
1006 .map(|i| format!("?{}", i + 1))
1007 .collect::<Vec<_>>()
1008 .join(",");
1009
1010 let sql = format!(
1012 "SELECT id, kind, content, npub, tags FROM events WHERE id IN ({})",
1013 placeholders
1014 );
1015 let mut stmt = conn.prepare(&sql)
1016 .map_err(|e| format!("Failed to prepare reply context query: {}", e))?;
1017
1018 let params: Vec<&str> = message_ids.iter().map(|s| s.as_str()).collect();
1019 let params_dyn: Vec<&dyn rusqlite::ToSql> = params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
1020
1021 let rows = stmt.query_map(params_dyn.as_slice(), |row| {
1022 Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?,
1023 row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?,
1024 row.get::<_, Option<String>>(4)?))
1025 }).map_err(|e| format!("Failed to query reply contexts: {}", e))?;
1026 let events_result: Vec<_> = rows.filter_map(|r| r.ok()).collect();
1027 drop(stmt);
1028
1029 let edit_sql = format!(
1031 "SELECT reference_id, content, tags FROM events \
1032 WHERE kind = {} AND reference_id IN ({}) \
1033 ORDER BY created_at DESC, received_at DESC",
1034 event_kind::MESSAGE_EDIT, placeholders
1035 );
1036 let mut edit_stmt = conn.prepare(&edit_sql)
1037 .map_err(|e| format!("Failed to prepare edit query: {}", e))?;
1038 let edit_rows = edit_stmt.query_map(params_dyn.as_slice(), |row| {
1039 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?,
1040 row.get::<_, Option<String>>(2)?))
1041 }).map_err(|e| format!("Failed to query edits: {}", e))?;
1042 let edits_result: Vec<_> = edit_rows.filter_map(|r| r.ok()).collect();
1043
1044 (events_result, edits_result)
1045 };
1046
1047 let mut latest_edits: HashMap<String, (String, Option<String>)> = HashMap::new();
1049 for (ref_id, content, tags) in edits {
1050 latest_edits.entry(ref_id).or_insert((content, tags));
1051 }
1052
1053 let file_ids: Vec<String> = events.iter()
1055 .filter(|(_, kind, _, _, _)| *kind == event_kind::FILE_ATTACHMENT as i32)
1056 .map(|(id, _, _, _, _)| id.clone())
1057 .collect();
1058 let atts_by_event = super::attachments::get_attachments_for_events(&file_ids).unwrap_or_default();
1059
1060 let mut contexts = HashMap::new();
1062 for (id, kind, original_content, npub, tags) in events {
1063 let has_attachment = kind == event_kind::FILE_ATTACHMENT as i32;
1064 let latest_edit = latest_edits.get(&id);
1065 let content_to_decrypt = latest_edit
1066 .map(|(c, _)| c.clone())
1067 .unwrap_or(original_content);
1068
1069 let parse_emoji = |json: &Option<String>| -> Vec<crate::types::EmojiTag> {
1072 json.as_deref()
1073 .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1074 .map(|parsed| crate::types::EmojiTag::extract_from_stored(&parsed))
1075 .unwrap_or_default()
1076 };
1077 let emoji_tags = match latest_edit {
1078 Some((_, edit_tags)) => parse_emoji(edit_tags),
1079 None => parse_emoji(&tags),
1080 };
1081
1082 let decrypted_content = if kind == event_kind::CHAT_MESSAGE as i32
1083 || kind == event_kind::PRIVATE_DIRECT_MESSAGE as i32
1084 {
1085 crate::crypto::maybe_decrypt(content_to_decrypt).await
1086 .unwrap_or_else(|_| "[Decryption failed]".to_string())
1087 } else {
1088 String::new()
1089 };
1090
1091 let extension = if has_attachment {
1094 atts_by_event.get(&id)
1095 .and_then(|atts| atts.first())
1096 .map(|a| a.extension.to_lowercase())
1097 .filter(|e| !e.is_empty())
1098 .or_else(|| tags.as_deref()
1099 .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1100 .and_then(|parsed| parsed.into_iter()
1101 .find(|t| t.first().map(|k| k == "attachments").unwrap_or(false))
1102 .and_then(|t| t.into_iter().nth(1)))
1103 .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(&json).ok())
1104 .and_then(|atts| atts.into_iter().next())
1105 .and_then(|a| a.get("extension").and_then(|e| e.as_str()).map(str::to_lowercase))
1106 .filter(|e| !e.is_empty()))
1107 } else {
1108 None
1109 };
1110
1111 contexts.insert(id, ReplyContext { content: decrypted_content, npub, has_attachment, extension, emoji_tags });
1112 }
1113
1114 Ok(contexts)
1115}
1116
1117pub async fn populate_reply_contexts(messages: Vec<&mut Message>) -> Result<(), String> {
1124 let ids: Vec<String> = messages
1125 .iter()
1126 .filter(|m| !m.replied_to.is_empty())
1127 .map(|m| m.replied_to.clone())
1128 .collect();
1129 if ids.is_empty() {
1130 return Ok(());
1131 }
1132 let contexts = get_reply_contexts(&ids).await?;
1133 for message in messages {
1134 if let Some(ctx) = contexts.get(&message.replied_to) {
1135 message.replied_to_content = Some(ctx.content.clone());
1136 message.replied_to_npub = ctx.npub.clone();
1137 message.replied_to_has_attachment = Some(ctx.has_attachment);
1138 message.replied_to_attachment_extension = ctx.extension.clone();
1139 message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1140 }
1141 }
1142 Ok(())
1143}
1144
1145pub async fn populate_reply_context(message: &mut Message) -> Result<(), String> {
1148 if message.replied_to.is_empty() {
1149 return Ok(());
1150 }
1151
1152 let contexts = get_reply_contexts(&[message.replied_to.clone()]).await?;
1153
1154 if let Some(ctx) = contexts.get(&message.replied_to) {
1155 message.replied_to_content = Some(ctx.content.clone());
1156 message.replied_to_npub = ctx.npub.clone();
1157 message.replied_to_has_attachment = Some(ctx.has_attachment);
1158 message.replied_to_attachment_extension = ctx.extension.clone();
1159 message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1160 }
1161
1162 Ok(())
1163}
1164
1165pub fn is_own_event(event_id: &str) -> bool {
1169 let Ok(conn) = super::get_db_connection_guard_static() else {
1170 return false;
1171 };
1172 conn.query_row(
1173 "SELECT mine FROM events WHERE id = ?1",
1174 [event_id],
1175 |row| row.get::<_, i64>(0),
1176 )
1177 .map(|mine| mine == 1)
1178 .unwrap_or(false)
1179}
1180
1181fn extract_tag_from_json(tags_json: &str, key: &str) -> Option<String> {
1187 if tags_json.len() <= 2 { return None; }
1188 let pattern = format!("[\"{}\"", key);
1189 if !tags_json.contains(&pattern) { return None; }
1190 let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1191 tags.into_iter()
1192 .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
1193 .and_then(|tag| tag.into_iter().nth(1))
1194}
1195
1196
1197fn normalize_reaction_author(author: String) -> String {
1201 if author.len() == 64 && author.bytes().all(|b| b.is_ascii_hexdigit()) {
1202 if let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(&author) {
1203 use nostr_sdk::prelude::ToBech32;
1204 let Ok(npub) = pk.to_bech32();
1205 return npub;
1206 }
1207 }
1208 author
1209}
1210fn extract_reaction_emoji_url(tags: &[Vec<String>], content: &str) -> Option<String> {
1216 if !content.starts_with(':') || !content.ends_with(':') || content.len() < 3 {
1217 return None;
1218 }
1219 let sc = &content[1..content.len() - 1];
1220 tags.iter().find_map(|t| {
1221 if t.len() >= 3 && t[0] == "emoji" && t[1] == sc {
1222 Some(t[2].clone())
1223 } else {
1224 None
1225 }
1226 })
1227}
1228
1229fn extract_reply_tag_from_json(tags_json: &str) -> Option<String> {
1231 if tags_json.len() <= 2 { return None; }
1232 if !tags_json.contains("[\"e\"") { return None; }
1233 let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1234 tags.into_iter()
1235 .find(|tag| {
1236 tag.first().map(|s| s.as_str()) == Some("e")
1237 && tag.get(3).map(|s| s.as_str()) == Some("reply")
1238 })
1239 .and_then(|tag| tag.into_iter().nth(1))
1240}
1241
1242pub async fn get_message_views(
1247 chat_id: i64,
1248 limit: usize,
1249 offset: usize,
1250) -> Result<Vec<Message>, String> {
1251 let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1253 let message_events = get_events(chat_id, Some(&message_kinds), limit, offset).await?;
1254
1255 compose_message_views(message_events).await
1256}
1257
1258async fn compose_message_views(message_events: Vec<StoredEvent>) -> Result<Vec<Message>, String> {
1263 use std::collections::HashMap;
1264
1265 if message_events.is_empty() {
1266 return Ok(Vec::new());
1267 }
1268
1269 let message_ids: Vec<String> = message_events.iter().map(|e| e.id.clone()).collect();
1271 let related_events = get_related_events(&message_ids).await?;
1272
1273 let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1274 let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1275
1276 for event in related_events {
1277 if let Some(ref_id) = &event.reference_id {
1278 match event.kind {
1279 k if k == event_kind::REACTION => {
1280 let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1281 let reaction = Reaction {
1282 id: event.id.clone(),
1283 reference_id: ref_id.clone(),
1284 author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1285 emoji: event.content.clone(),
1286 emoji_url,
1287 };
1288 let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1291 if !slot.iter().any(|r| r.same_slot(&reaction)) {
1292 slot.push(reaction);
1293 }
1294 }
1295 k if k == event_kind::MESSAGE_EDIT => {
1296 let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1297 .unwrap_or_else(|_| event.content.clone());
1298 let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1299 edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1300 }
1301 _ => {}
1302 }
1303 }
1304 }
1305
1306 for edits in edits_by_msg.values_mut() {
1307 edits.sort_by_key(|(ts, _, _)| *ts);
1308 }
1309
1310 let attach_ids: Vec<String> = message_events.iter()
1313 .filter(|e| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1314 .map(|e| e.id.clone())
1315 .collect();
1316 let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1317 .unwrap_or_default();
1318 for event in &message_events {
1319 if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1320 continue;
1321 }
1322 if attachments_by_msg.contains_key(&event.id) {
1323 continue;
1324 }
1325 if let Some(json) = event.get_tag("attachments") {
1326 if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(json) {
1327 if !atts.is_empty() {
1328 attachments_by_msg.insert(event.id.clone(), atts);
1329 }
1330 }
1331 }
1332 }
1333
1334 let mut messages = Vec::with_capacity(message_events.len());
1336 for event in message_events {
1337 let replied_to = event.get_reply_reference().unwrap_or("").to_string();
1338 let at = event.timestamp_ms();
1339 let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1340 let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1341
1342 let original_content = if event.kind == event_kind::FILE_ATTACHMENT {
1343 String::new()
1344 } else {
1345 event.content.clone()
1346 };
1347
1348 let original_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1351 let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1352 let mut history = Vec::with_capacity(edits.len() + 1);
1353 history.push(crate::types::EditEntry { content: original_content.clone(), edited_at: at });
1354 for (ts, c, _) in &edits {
1355 history.push(crate::types::EditEntry { content: c.clone(), edited_at: *ts });
1356 }
1357 let (latest, latest_emoji) = edits.last()
1358 .map(|(_, c, e)| (c.clone(), e.clone()))
1359 .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1360 (latest, true, Some(history), latest_emoji)
1361 } else {
1362 (original_content, false, None, original_emoji)
1363 };
1364
1365 let preview_metadata = event.preview_metadata
1366 .and_then(|json| serde_json::from_str(&json).ok());
1367
1368 let addressed_bots = extract_bot_tags(&event.tags);
1369 let expiration = extract_expiration_tag(&event.tags);
1370 messages.push(Message {
1371 expiration,
1372 id: event.id, content, replied_to,
1373 replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1374 replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1375 preview_metadata, attachments, reactions, at,
1376 pending: event.pending, failed: event.failed, mine: event.mine,
1377 npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1378 edited, edit_history,
1379 emoji_tags,
1380 addressed_bots,
1381 });
1382 }
1383
1384 crate::self_destruct::strip_expired(&mut messages);
1388
1389 let reply_ids: Vec<String> = messages.iter()
1391 .filter(|m| !m.replied_to.is_empty())
1392 .map(|m| m.replied_to.clone())
1393 .collect();
1394
1395 if !reply_ids.is_empty() {
1396 let contexts = get_reply_contexts(&reply_ids).await?;
1397 for msg in &mut messages {
1398 if let Some(ctx) = contexts.get(&msg.replied_to) {
1399 msg.replied_to_content = Some(ctx.content.clone());
1400 msg.replied_to_npub = ctx.npub.clone();
1401 msg.replied_to_has_attachment = Some(ctx.has_attachment);
1402 msg.replied_to_attachment_extension = ctx.extension.clone();
1403 }
1404 }
1405 }
1406
1407 Ok(messages)
1408}
1409
1410pub async fn get_message_by_id(message_id: &str) -> Result<Option<(String, Message)>, String> {
1425 let kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT]
1426 .map(|k| k.to_string())
1427 .join(",");
1428 let found = {
1429 let conn = super::get_db_connection_guard_static()?;
1430 let sql = format!(
1432 "SELECT e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1433 e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, \
1434 e.preview_metadata, c.chat_identifier \
1435 FROM events e JOIN chats c ON c.id = e.chat_id \
1436 WHERE e.id = ?1 AND e.kind IN ({kinds})"
1437 );
1438 let mut stmt = conn.prepare(&sql).map_err(|e| format!("prepare get_message_by_id: {e}"))?;
1439 stmt.query_row(rusqlite::params![message_id], |r| Ok((parse_event_row(r)?, r.get::<_, String>(15)?)))
1440 .optional()
1441 .map_err(|e| format!("query get_message_by_id: {e}"))?
1442 };
1443 let Some((event, chat_identifier)) = found else { return Ok(None) };
1444 Ok(compose_message_views(vec![event]).await?.pop().map(|m| (chat_identifier, m)))
1445}
1446
1447pub async fn get_messages_around(
1448 chat_id: i64,
1449 anchor_id: &str,
1450 before: usize,
1451 after: usize,
1452) -> Result<Vec<Message>, String> {
1453 let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1454
1455 let message_events: Vec<StoredEvent> = {
1456 let conn = super::get_db_connection_guard_static()?;
1457 let community = community_of_chat(&conn, chat_id);
1458
1459 let (anchor_at, anchor_rt, anchor_rowid): (i64, i64, i64) = conn.query_row(
1465 "SELECT created_at, received_at, rowid FROM events WHERE id = ?1",
1466 rusqlite::params![anchor_id],
1467 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1468 ).map_err(|e| format!("Anchor message not found: {}", e))?;
1469
1470 let kind_placeholders: String = (0..message_kinds.len())
1472 .map(|i| format!("?{}", i + 2))
1473 .collect::<Vec<_>>()
1474 .join(",");
1475 let cols = "id, kind, chat_id, user_id, content, tags, reference_id, \
1476 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata";
1477
1478 let older_sql = format!(
1480 "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1481 AND (created_at < ?5 OR (created_at = ?5 AND (received_at < ?6 \
1482 OR (received_at = ?6 AND rowid <= ?7)))) \
1483 {} ORDER BY created_at DESC, received_at DESC, rowid DESC LIMIT ?8",
1484 cols, kind_placeholders, ban_filter_sql(9)
1485 );
1486 let mut older_stmt = conn.prepare(&older_sql)
1487 .map_err(|e| format!("Failed to prepare older window query: {}", e))?;
1488 let older_rows = older_stmt.query_map(
1489 rusqlite::params![
1490 chat_id,
1491 message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1492 anchor_at, anchor_rt, anchor_rowid, before as i64, community
1493 ],
1494 parse_event_row,
1495 ).map_err(|e| format!("Failed to query older window: {}", e))?;
1496 let mut older: Vec<StoredEvent> = older_rows.filter_map(|r| r.ok()).collect();
1497 older.reverse(); let newer_sql = format!(
1501 "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1502 AND (created_at > ?5 OR (created_at = ?5 AND (received_at > ?6 \
1503 OR (received_at = ?6 AND rowid > ?7)))) \
1504 {} ORDER BY created_at ASC, received_at ASC, rowid ASC LIMIT ?8",
1505 cols, kind_placeholders, ban_filter_sql(9)
1506 );
1507 let mut newer_stmt = conn.prepare(&newer_sql)
1508 .map_err(|e| format!("Failed to prepare newer window query: {}", e))?;
1509 let newer_rows = newer_stmt.query_map(
1510 rusqlite::params![
1511 chat_id,
1512 message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1513 anchor_at, anchor_rt, anchor_rowid, after as i64, community
1514 ],
1515 parse_event_row,
1516 ).map_err(|e| format!("Failed to query newer window: {}", e))?;
1517 let newer: Vec<StoredEvent> = newer_rows.filter_map(|r| r.ok()).collect();
1518
1519 older.into_iter().chain(newer).collect()
1520 };
1521
1522 let mut decrypted = Vec::with_capacity(message_events.len());
1524 for mut event in message_events {
1525 if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
1526 event.content = crate::crypto::maybe_decrypt(event.content).await
1527 .unwrap_or_else(|_| "[Decryption failed]".to_string());
1528 }
1529 decrypted.push(event);
1530 }
1531
1532 compose_message_views(decrypted).await
1533}
1534
1535pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
1538 use std::collections::HashMap;
1539
1540 let message_events: Vec<(String, StoredEvent, String)> = {
1542 let conn = super::get_db_connection_guard_static()?;
1543 let mut stmt = conn.prepare(
1544 "SELECT c.chat_identifier, \
1545 e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1546 e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, e.preview_metadata \
1547 FROM chats c JOIN events e ON e.rowid = ( \
1548 SELECT e2.rowid FROM events e2 WHERE e2.chat_id = c.id \
1549 AND e2.kind IN (?1, ?2, ?3) \
1550 ORDER BY e2.created_at DESC, e2.received_at DESC LIMIT 1) \
1551 WHERE c.chat_type != 1"
1552 ).map_err(|e| format!("Failed to prepare: {}", e))?;
1553
1554 let rows = stmt.query_map(
1555 rusqlite::params![
1556 event_kind::CHAT_MESSAGE as i32,
1557 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1558 event_kind::FILE_ATTACHMENT as i32
1559 ],
1560 |row| {
1561 let chat_id: String = row.get(0)?;
1562 let tags_json: String = row.get(6)?;
1563 let event = StoredEvent {
1564 id: row.get(1)?, kind: row.get::<_, i32>(2)? as u16,
1565 chat_id: row.get(3)?, user_id: row.get(4)?, content: row.get(5)?,
1566 tags: Vec::new(), reference_id: row.get(7)?,
1568 created_at: row.get::<_, i64>(8)? as u64, received_at: row.get::<_, i64>(9)? as u64,
1569 mine: row.get::<_, i32>(10)? != 0, pending: row.get::<_, i32>(11)? != 0,
1570 failed: row.get::<_, i32>(12)? != 0, wrapper_event_id: row.get(13)?,
1571 npub: row.get(14)?, preview_metadata: row.get(15)?,
1572 };
1573 Ok((chat_id, event, tags_json))
1574 }
1575 ).map_err(|e| format!("Failed to query: {}", e))?;
1576 rows.filter_map(|r| r.ok()).collect()
1577 };
1578
1579 if message_events.is_empty() {
1580 return Ok(HashMap::new());
1581 }
1582
1583 let message_ids: Vec<String> = message_events.iter().map(|(_, e, _)| e.id.clone()).collect();
1585 let related_events = get_related_events(&message_ids).await?;
1586
1587 let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1588 let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1589
1590 for event in related_events {
1591 if let Some(ref_id) = &event.reference_id {
1592 match event.kind {
1593 k if k == event_kind::REACTION => {
1594 let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1595 let reaction = Reaction {
1596 id: event.id.clone(), reference_id: ref_id.clone(),
1597 author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1598 emoji: event.content.clone(),
1599 emoji_url,
1600 };
1601 let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1604 if !slot.iter().any(|r| r.same_slot(&reaction)) {
1605 slot.push(reaction);
1606 }
1607 }
1608 k if k == event_kind::MESSAGE_EDIT => {
1609 let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1610 .unwrap_or_else(|_| event.content.clone());
1611 let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1612 edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1613 }
1614 _ => {}
1615 }
1616 }
1617 }
1618 for edits in edits_by_msg.values_mut() {
1619 edits.sort_by_key(|(ts, _, _)| *ts);
1620 }
1621
1622 let attach_ids: Vec<String> = message_events.iter()
1624 .filter(|(_, e, _)| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1625 .map(|(_, e, _)| e.id.clone())
1626 .collect();
1627 let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1628 .unwrap_or_default();
1629 for (_, event, tags_json) in &message_events {
1630 if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1631 continue;
1632 }
1633 if attachments_by_msg.contains_key(&event.id) {
1634 continue;
1635 }
1636 if let Some(val) = extract_tag_from_json(tags_json, "attachments") {
1637 if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&val) {
1638 if !atts.is_empty() {
1639 attachments_by_msg.insert(event.id.clone(), atts);
1640 }
1641 }
1642 }
1643 }
1644
1645 let mut result: HashMap<String, Vec<Message>> = HashMap::new();
1647
1648 for (chat_identifier, event, tags_json) in message_events {
1649 let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1650 let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1651 let replied_to = extract_reply_tag_from_json(&tags_json).unwrap_or_default();
1652
1653 let original_content = if event.kind == event_kind::CHAT_MESSAGE
1655 || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
1656 {
1657 crate::crypto::maybe_decrypt(event.content.clone()).await
1658 .unwrap_or_else(|_| "[Decryption failed]".to_string())
1659 } else {
1660 String::new()
1661 };
1662
1663 let stored_tags = serde_json::from_str::<Vec<Vec<String>>>(&tags_json).unwrap_or_default();
1664 let original_emoji = crate::types::EmojiTag::extract_from_stored(&stored_tags);
1665 let addressed_bots = extract_bot_tags(&stored_tags);
1666 let expiration = extract_expiration_tag(&stored_tags);
1667 let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1669 let (latest, latest_emoji) = edits.last()
1670 .map(|(_, c, e)| (c.clone(), e.clone()))
1671 .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1672 let history: Vec<crate::types::EditEntry> = std::iter::once(crate::types::EditEntry {
1673 content: original_content, edited_at: event.created_at * 1000,
1674 }).chain(edits.into_iter().map(|(ts, c, _)| crate::types::EditEntry { content: c, edited_at: ts }))
1675 .collect();
1676 (latest, true, Some(history), latest_emoji)
1677 } else {
1678 (original_content, false, None, original_emoji)
1679 };
1680
1681 let preview_metadata = event.preview_metadata
1682 .and_then(|json| serde_json::from_str(&json).ok());
1683
1684 result.entry(chat_identifier).or_default().push(Message {
1685 expiration,
1686 id: event.id, content, replied_to,
1687 replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1688 replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1689 preview_metadata, attachments, reactions, at: event.created_at * 1000,
1690 pending: event.pending, failed: event.failed, mine: event.mine,
1691 npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1692 edited, edit_history,
1693 emoji_tags,
1694 addressed_bots,
1695 });
1696 }
1697
1698 let reply_ids: Vec<String> = result.values()
1702 .flatten()
1703 .filter(|m| !m.replied_to.is_empty())
1704 .map(|m| m.replied_to.clone())
1705 .collect();
1706
1707 if !reply_ids.is_empty() {
1708 let contexts = get_reply_contexts(&reply_ids).await?;
1709 for msg in result.values_mut().flatten() {
1710 if let Some(ctx) = contexts.get(&msg.replied_to) {
1711 msg.replied_to_content = Some(ctx.content.clone());
1712 msg.replied_to_npub = ctx.npub.clone();
1713 msg.replied_to_has_attachment = Some(ctx.has_attachment);
1714 msg.replied_to_attachment_extension = ctx.extension.clone();
1715 }
1716 }
1717 }
1718
1719 for msgs in result.values_mut() {
1723 crate::self_destruct::strip_expired(msgs);
1724 }
1725
1726 Ok(result)
1727}
1728
1729pub async fn unread_counts() -> Result<std::collections::HashMap<String, u32>, String> {
1740 let conn = super::get_db_connection_guard_static()?;
1741 let mut stmt = conn
1747 .prepare(
1748 "WITH anchors AS ( \
1749 SELECT c.id AS chat_id, c.chat_identifier AS chat_identifier, \
1750 COALESCE(MAX(e.created_at), 0) AS anchor_ts \
1751 FROM chats c \
1752 LEFT JOIN events e ON e.chat_id = c.id \
1753 AND ((e.mine = 1 AND e.kind IN (?1, ?2, ?3)) OR e.id = c.last_read) \
1754 GROUP BY c.id \
1755 ) \
1756 SELECT a.chat_identifier, COUNT(*) AS unread \
1757 FROM events e JOIN anchors a ON a.chat_id = e.chat_id \
1758 WHERE e.kind IN (?1, ?2, ?3) AND e.mine = 0 AND e.created_at > a.anchor_ts \
1759 AND (e.npub IS NULL OR e.npub NOT IN ( \
1760 SELECT chat_identifier FROM chats WHERE muted = 1 \
1761 UNION \
1762 SELECT npub FROM profiles WHERE is_blocked = 1)) \
1763 AND NOT EXISTS ( \
1764 SELECT 1 FROM community_channels cc \
1765 JOIN community_bans b ON b.community_id = cc.community_id AND b.npub = e.npub \
1766 WHERE cc.channel_id = a.chat_identifier) \
1767 GROUP BY a.chat_identifier",
1768 )
1769 .map_err(|e| format!("prepare unread_counts: {e}"))?;
1770 let rows = stmt
1771 .query_map(
1772 rusqlite::params![
1773 event_kind::CHAT_MESSAGE as i32,
1774 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1775 event_kind::FILE_ATTACHMENT as i32
1776 ],
1777 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)),
1778 )
1779 .map_err(|e| format!("query unread_counts: {e}"))?;
1780 let mut out = std::collections::HashMap::new();
1781 for r in rows.flatten() {
1782 out.insert(r.0, r.1);
1783 }
1784 Ok(out)
1785}
1786
1787pub async fn unread_count_for_chat(chat_identifier: &str) -> Result<u32, String> {
1790 let conn = super::get_db_connection_guard_static()?;
1791 let count: i64 = conn
1792 .query_row(
1793 "SELECT COUNT(*) FROM events e JOIN chats c ON e.chat_id = c.id \
1794 WHERE c.chat_identifier = ?4 AND e.kind IN (?1, ?2, ?3) AND e.mine = 0 \
1795 AND (e.npub IS NULL OR e.npub NOT IN ( \
1796 SELECT chat_identifier FROM chats WHERE muted = 1 \
1797 UNION \
1798 SELECT npub FROM profiles WHERE is_blocked = 1)) \
1799 AND NOT EXISTS ( \
1800 SELECT 1 FROM community_channels cc \
1801 JOIN community_bans b ON b.community_id = cc.community_id AND b.npub = e.npub \
1802 WHERE cc.channel_id = c.chat_identifier) \
1803 AND e.created_at > COALESCE(( \
1804 SELECT MAX(e2.created_at) FROM events e2 \
1805 WHERE e2.chat_id = c.id \
1806 AND ((e2.mine = 1 AND e2.kind IN (?1, ?2, ?3)) OR e2.id = c.last_read)), 0)",
1807 rusqlite::params![
1808 event_kind::CHAT_MESSAGE as i32,
1809 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1810 event_kind::FILE_ATTACHMENT as i32,
1811 chat_identifier
1812 ],
1813 |row| row.get(0),
1814 )
1815 .map_err(|e| format!("query unread_count_for_chat: {e}"))?;
1816 Ok(count as u32)
1817}
1818
1819#[derive(Debug, PartialEq)]
1823pub enum UnreadMark {
1824 NoOp,
1826 Clear,
1828 Anchor(String),
1830}
1831
1832pub async fn compute_unread_anchor(chat_identifier: &str) -> Result<UnreadMark, String> {
1836 let conn = super::get_db_connection_guard_static()?;
1837 let (k0, k1, k2) = (
1838 event_kind::CHAT_MESSAGE as i32,
1839 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1840 event_kind::FILE_ATTACHMENT as i32,
1841 );
1842 let (target_ts, newest_ts): (Option<i64>, Option<i64>) = conn
1844 .query_row(
1845 "SELECT MAX(CASE WHEN e.mine = 0 THEN e.created_at END), MAX(e.created_at) \
1846 FROM events e JOIN chats c ON e.chat_id = c.id \
1847 WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4)",
1848 rusqlite::params![chat_identifier, k0, k1, k2],
1849 |row| Ok((row.get(0)?, row.get(1)?)),
1850 )
1851 .map_err(|e| format!("unread anchor target: {e}"))?;
1852
1853 let target_ts = match target_ts {
1854 Some(t) => t,
1855 None => return Ok(UnreadMark::NoOp), };
1857 if newest_ts.map_or(false, |n| n > target_ts) {
1858 return Ok(UnreadMark::NoOp); }
1860
1861 let anchor_id: Option<String> = conn
1862 .query_row(
1863 "SELECT e.id FROM events e JOIN chats c ON e.chat_id = c.id \
1864 WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4) AND e.created_at < ?5 \
1865 ORDER BY e.created_at DESC LIMIT 1",
1866 rusqlite::params![chat_identifier, k0, k1, k2, target_ts],
1867 |row| row.get(0),
1868 )
1869 .optional()
1870 .map_err(|e| format!("unread anchor prev: {e}"))?;
1871
1872 Ok(match anchor_id {
1873 Some(id) => UnreadMark::Anchor(id),
1874 None => UnreadMark::Clear,
1875 })
1876}
1877
1878pub async fn flush_message_batch(
1885 chat_id: &str,
1886 pending: &mut Vec<&Message>,
1887 session: &std::sync::Arc<crate::db::Session>,
1888) {
1889 crate::db::scoped(async move {
1890 if pending.is_empty() {
1891 return;
1892 }
1893 if !session.is_live() {
1894 pending.clear();
1895 return;
1896 }
1897 if let Err(e) = save_messages_batch(chat_id, pending).await {
1898 crate::log_warn!("[DB] batch flush failed for {}: {}", chat_id, e);
1899 }
1900 pending.clear();
1901 })
1902 .await
1903}
1904
1905pub async fn save_chat_messages(chat_id: &str, messages: &[Message]) -> Result<(), String> {
1907 if messages.is_empty() {
1908 return Ok(());
1909 }
1910 let refs: Vec<&Message> = messages.iter().collect();
1911 save_messages_batch(chat_id, &refs).await.map(|_| ())
1912}
1913
1914#[cfg(test)]
1915mod tests {
1916 use super::*;
1917 use crate::stored_event::SystemEventType;
1918
1919 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(71000);
1920
1921 fn make_test_npub(n: u32) -> String {
1922 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1923 let mut payload = vec![b'q'; 58];
1924 let mut x = n as u64;
1925 let mut i = 58;
1926 while x > 0 && i > 0 {
1927 i -= 1;
1928 payload[i] = BECH32[(x as usize) % 32];
1929 x /= 32;
1930 }
1931 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1932 }
1933
1934 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1935 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1936 crate::db::close_database();
1937 crate::db::clear_id_caches();
1940 let tmp = tempfile::tempdir().unwrap();
1941 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1942 let account = make_test_npub(n);
1943 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1944 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1945 crate::db::set_current_account(account.clone()).unwrap();
1946 crate::db::init_database(&account).unwrap();
1947 (tmp, guard)
1948 }
1949
1950 fn now_secs() -> u64 {
1951 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
1952 }
1953
1954 #[tokio::test]
1961 async fn batch_resolver_fills_quotes_for_a_synced_page() {
1962 let (_tmp, _guard) = init_test_db();
1963 let chat = "channel_reply_batch";
1964 let author = make_test_npub(90_001);
1965
1966 let mut parent = Message::default();
1968 parent.id = "parent_evt".to_string();
1969 parent.content = "the original message".to_string();
1970 parent.npub = Some(author.clone());
1971 parent.at = now_secs() - 5_000;
1972 save_message(chat, &parent).await.unwrap();
1973
1974 let mut reply = Message::default();
1976 reply.id = "reply_evt".to_string();
1977 reply.content = "replying now".to_string();
1978 reply.replied_to = "parent_evt".to_string();
1979 reply.at = now_secs();
1980 let mut plain = Message::default();
1981 plain.id = "plain_evt".to_string();
1982 plain.content = "unrelated".to_string();
1983 plain.at = now_secs();
1984 let mut orphan = Message::default();
1986 orphan.id = "orphan_evt".to_string();
1987 orphan.replied_to = "never_seen_evt".to_string();
1988 orphan.at = now_secs();
1989
1990 populate_reply_contexts(vec![&mut reply, &mut plain, &mut orphan]).await.unwrap();
1991
1992 assert_eq!(
1993 reply.replied_to_content.as_deref(),
1994 Some("the original message"),
1995 "the synced reply carries its parent's content"
1996 );
1997 assert_eq!(reply.replied_to_npub.as_deref(), Some(author.as_str()), "and its parent's author");
1998 assert!(plain.replied_to_content.is_none(), "a non-reply is untouched");
1999 assert!(orphan.replied_to_content.is_none(), "an unresolvable parent leaves the quote empty");
2000 }
2001
2002 #[tokio::test]
2005 async fn a_message_resolves_by_id_with_the_chat_it_was_said_in() {
2006 let (_tmp, _guard) = init_test_db();
2007 let chat = "channel_resolve_by_id";
2008 let author = make_test_npub(91_001);
2009
2010 let mut msg = Message::default();
2011 msg.id = "cited_evt".to_string();
2012 msg.content = "the line a rule matched".to_string();
2013 msg.npub = Some(author.clone());
2014 msg.at = now_secs();
2015 save_message(chat, &msg).await.unwrap();
2016
2017 let (found_chat, found) = get_message_by_id("cited_evt").await.unwrap().expect("stored message resolves");
2018 assert_eq!(found_chat, chat, "the citation gets the room back, not just the message");
2019 assert_eq!(found.content, "the line a rule matched", "and the text, so a mod log can quote it");
2020 assert_eq!(found.npub.as_deref(), Some(author.as_str()));
2021 }
2022
2023 #[tokio::test]
2027 async fn a_missing_message_resolves_to_none_rather_than_erroring() {
2028 let (_tmp, _guard) = init_test_db();
2029 assert!(get_message_by_id("never_stored_evt").await.unwrap().is_none());
2030 }
2031
2032 #[tokio::test]
2035 async fn an_attachment_hash_finds_every_message_that_carried_it() {
2036 let (_tmp, _guard) = init_test_db();
2037 let chat = "channel_same_blob";
2038 let hash = "cc".repeat(32);
2039
2040 let now_ms = now_secs() * 1000;
2043 for (id, at) in [("blob_older", now_ms - 500_000), ("blob_newer", now_ms)] {
2044 let mut msg = Message::default();
2045 msg.id = id.to_string();
2046 msg.npub = Some(make_test_npub(92_001));
2047 msg.at = at;
2048 msg.attachments = vec![crate::types::Attachment {
2049 id: hash.clone(),
2050 extension: "png".into(),
2051 name: "poster.png".into(),
2052 ..Default::default()
2053 }];
2054 save_message(chat, &msg).await.unwrap();
2055 }
2056
2057 let carriers = crate::db::attachments::events_with_attachment_hash(&hash).unwrap();
2058 assert_eq!(carriers, vec!["blob_newer", "blob_older"], "every carrier, newest first");
2059
2060 for id in ["tie_a", "tie_b"] {
2063 let mut msg = Message::default();
2064 msg.id = id.to_string();
2065 msg.npub = Some(make_test_npub(92_002));
2066 msg.at = now_ms;
2067 msg.attachments = vec![crate::types::Attachment { id: hash.clone(), ..Default::default() }];
2068 save_message(chat, &msg).await.unwrap();
2069 }
2070 let a = crate::db::attachments::events_with_attachment_hash(&hash).unwrap();
2071 let b = crate::db::attachments::events_with_attachment_hash(&hash).unwrap();
2072 assert_eq!(a, b, "a same-instant cluster orders the same way every read");
2073 assert_eq!(a.len(), 4, "and every carrier is still listed");
2074 assert!(
2075 crate::db::attachments::events_with_attachment_hash(&"ab".repeat(32)).unwrap().is_empty(),
2076 "a hash nobody posted finds nothing"
2077 );
2078 }
2079
2080 #[tokio::test]
2083 async fn batch_resolver_is_a_noop_without_replies() {
2084 let (_tmp, _guard) = init_test_db();
2085 populate_reply_contexts(vec![]).await.unwrap();
2086 let mut plain = Message::default();
2087 plain.id = "solo".to_string();
2088 populate_reply_contexts(vec![&mut plain]).await.unwrap();
2089 assert!(plain.replied_to_content.is_none());
2090 }
2091
2092 #[tokio::test]
2096 async fn system_event_stamps_authenticated_time_clamped_to_now() {
2097 let (_tmp, _guard) = init_test_db();
2098 let chat = "channel_ch2_timestamp";
2099 let before = now_secs();
2100 let past = before - 100_000;
2101
2102 save_system_event_at("ev_past", chat, SystemEventType::MemberJoined, "npubX", None, past, None, None).await.unwrap();
2103 save_system_event_at("ev_future", chat, SystemEventType::MemberJoined, "npubX", None, before + 100_000, None, None).await.unwrap();
2104 let after = now_secs();
2105
2106 let evs = get_system_events_for_chat(chat).unwrap();
2107 let past_ev = evs.iter().find(|e| e.id == "ev_past").expect("past event saved");
2108 assert_eq!(past_ev.created_at, past, "historical join keeps its real (authenticated) timestamp");
2109
2110 let fut_ev = evs.iter().find(|e| e.id == "ev_future").expect("future event saved");
2111 assert!(fut_ev.created_at >= before && fut_ev.created_at <= after,
2112 "future-dated event clamped to local now ({} not in {}..={})", fut_ev.created_at, before, after);
2113 }
2114
2115 #[tokio::test]
2118 async fn event_delete_context_resolves_from_db() {
2119 let (_tmp, _guard) = init_test_db();
2120 let chat = "npub1contactdc";
2121
2122 let mine_msg = Message { id: "dc_mine".into(), content: "x".into(), at: 1_000, mine: true, ..Default::default() };
2123 let theirs = Message {
2124 id: "dc_theirs".into(), content: "y".into(), at: 2_000, mine: false,
2125 npub: Some("npub1sender".to_string()),
2126 ..Default::default()
2127 };
2128 save_message(chat, &mine_msg).await.unwrap();
2129 save_message(chat, &theirs).await.unwrap();
2130
2131 let (chat_id, mine, _author) = event_delete_context("dc_mine").unwrap().expect("own row resolves");
2132 assert_eq!(chat_id, chat);
2133 assert!(mine);
2134
2135 let (chat_id, mine, author) = event_delete_context("dc_theirs").unwrap().expect("contact row resolves");
2136 assert_eq!(chat_id, chat);
2137 assert!(!mine);
2138 assert_eq!(author.as_deref(), Some("npub1sender"));
2139
2140 assert!(event_delete_context("dc_absent").unwrap().is_none(), "unknown id is None, not an error");
2141 }
2142
2143 #[tokio::test]
2146 async fn unread_counts_match_walk_back_semantics() {
2147 let (_tmp, _guard) = init_test_db();
2148 let chat = "npub1contactdm";
2149 let mk = |id: &str, secs: u64, mine: bool| Message {
2151 id: id.into(), content: "x".into(), at: secs * 1000, mine,
2152 npub: (!mine).then(|| "npub1sender".to_string()),
2153 ..Default::default()
2154 };
2155 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2156
2157 for i in 0..6u64 {
2159 save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap();
2160 }
2161 assert_eq!(unread().await, 6, "never-read backlog counts all 6");
2162
2163 save_message(chat, &mk("m6", 2000, false)).await.unwrap();
2165 save_message(chat, &mk("m7", 2001, false)).await.unwrap();
2166 assert_eq!(unread().await, 8, "6 backlog + 2 new = 8");
2167
2168 save_message(chat, &mk("mine", 2002, true)).await.unwrap();
2170 assert_eq!(unread().await, 0, "own message = read up to here");
2171
2172 save_message(chat, &mk("m8", 2003, false)).await.unwrap();
2174 assert_eq!(unread().await, 1, "one new after our send");
2175
2176 {
2178 let conn = crate::db::get_write_connection_guard_static().unwrap();
2179 conn.execute(
2180 "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2181 rusqlite::params!["m8", chat],
2182 ).unwrap();
2183 }
2184 assert_eq!(unread().await, 0, "last_read=m8 clears all");
2185 save_message(chat, &mk("m9", 2004, false)).await.unwrap();
2186 assert_eq!(unread().await, 1, "one arrival after last_read");
2187 }
2188
2189 #[tokio::test]
2192 async fn unread_counts_exclude_muted_and_blocked_senders() {
2193 let (_tmp, _guard) = init_test_db();
2194 let chat = "channel_mute_exclusion";
2195 let mk = |id: &str, secs: u64, npub: &str| Message {
2196 id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2197 npub: Some(npub.to_string()),
2198 ..Default::default()
2199 };
2200 save_message(chat, &mk("n1", 1000, "npub1noisy")).await.unwrap();
2201 save_message(chat, &mk("q1", 1001, "npub1quiet")).await.unwrap();
2202
2203 let map = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2204 let one = || async { unread_count_for_chat(chat).await.unwrap() };
2205 assert_eq!(map().await, 2, "both senders count while neither is muted");
2206 assert_eq!(one().await, 2);
2207
2208 {
2210 let conn = crate::db::get_write_connection_guard_static().unwrap();
2211 conn.execute(
2212 "INSERT INTO chats (chat_identifier, chat_type, participants, created_at, muted) \
2213 VALUES ('npub1noisy', 0, '', 1000, 1)",
2214 [],
2215 ).unwrap();
2216 }
2217 assert_eq!(map().await, 1, "muted sender's message stops counting");
2218 assert_eq!(one().await, 1);
2219
2220 {
2222 let conn = crate::db::get_write_connection_guard_static().unwrap();
2223 conn.execute(
2224 "INSERT INTO profiles (npub, is_blocked) VALUES ('npub1quiet', 1) \
2225 ON CONFLICT(npub) DO UPDATE SET is_blocked = 1",
2226 [],
2227 ).unwrap();
2228 }
2229 assert_eq!(map().await, 0, "blocked sender's message stops counting too");
2230 assert_eq!(one().await, 0);
2231
2232 {
2234 let conn = crate::db::get_write_connection_guard_static().unwrap();
2235 conn.execute("UPDATE chats SET muted = 0 WHERE chat_identifier = 'npub1noisy'", []).unwrap();
2236 }
2237 assert_eq!(map().await, 1, "unmuting restores the sender's count");
2238 assert_eq!(one().await, 1);
2239 }
2240
2241 #[tokio::test]
2244 async fn unread_count_for_chat_matches_the_map() {
2245 let (_tmp, _guard) = init_test_db();
2246 let chat = "npub1reconcile";
2247 let mk = |id: &str, secs: u64, mine: bool| Message {
2248 id: id.into(), content: "x".into(), at: secs * 1000, mine,
2249 npub: (!mine).then(|| "npub1sender".to_string()),
2250 ..Default::default()
2251 };
2252 let agree = || async {
2253 let map = unread_counts().await.unwrap().get(chat).copied().unwrap_or(0);
2254 let one = unread_count_for_chat(chat).await.unwrap();
2255 assert_eq!(map, one, "single-chat query diverged from the map");
2256 one
2257 };
2258
2259 for i in 0..4u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap(); }
2260 assert_eq!(agree().await, 4);
2261 save_message(chat, &mk("mine", 1010, true)).await.unwrap();
2262 assert_eq!(agree().await, 0);
2263 save_message(chat, &mk("after", 1011, false)).await.unwrap();
2264 assert_eq!(agree().await, 1);
2265 {
2266 let conn = crate::db::get_write_connection_guard_static().unwrap();
2267 conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2268 rusqlite::params!["after", chat]).unwrap();
2269 }
2270 assert_eq!(agree().await, 0);
2271 assert_eq!(unread_count_for_chat("npub1nonexistent").await.unwrap(), 0);
2273 }
2274
2275 #[tokio::test]
2276 async fn attachments_table_round_trip_dedup_and_cascade() {
2277 let (_tmp, _guard) = init_test_db();
2278 let att = |id: &str, name: &str| Attachment {
2280 id: id.into(), url: format!("https://blossom/{id}"), name: name.into(),
2281 extension: "png".into(), size: 42, downloaded: false, ..Default::default()
2282 };
2283 let msg = |mid: &str, secs: u64, atts: Vec<Attachment>| Message {
2284 id: mid.into(), content: String::new(), at: secs * 1000, mine: false,
2285 npub: Some("npub1sender".into()), attachments: atts, ..Default::default()
2286 };
2287
2288 save_message("npub1att", &msg("m1", 1000, vec![att("hashA", "a.png"), att("hashB", "b.png")])).await.unwrap();
2290 let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2291 assert_eq!(got.len(), 2);
2292 assert_eq!((got[0].id.as_str(), got[1].id.as_str()), ("hashA", "hashB"), "att_index order");
2293 assert_eq!(got[0].name, "a.png");
2294 assert_eq!(got[0].size, 42);
2295 assert!(!got[0].downloaded);
2296
2297 crate::db::attachments::set_attachment_downloaded("m1", "hashA", true, "/tmp/a.png").unwrap();
2299 let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2300 assert!(got[0].downloaded && got[0].path == "/tmp/a.png");
2301 assert!(!got[1].downloaded, "sibling attachment untouched");
2302
2303 save_message("npub1att", &msg("m2", 1001, vec![att("hashA", "a-again.png")])).await.unwrap();
2305 let affected = crate::db::attachments::backfill_downloaded_by_hash("hashA", "/tmp/a.png", "m1").unwrap();
2306 assert_eq!(affected, vec!["m2".to_string()]);
2307 assert!(crate::db::attachments::get_attachments_for_event("m2").unwrap()[0].downloaded);
2308
2309 let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1att").unwrap();
2312 let views = get_message_views(chat_int, 10, 0).await.unwrap();
2313 let m1 = views.iter().find(|m| m.id == "m1").unwrap();
2314 assert_eq!(m1.attachments.len(), 2);
2315
2316 delete_event("m1").await.unwrap();
2318 assert!(crate::db::attachments::get_attachments_for_event("m1").unwrap().is_empty(), "ON DELETE CASCADE");
2319 }
2320
2321 #[tokio::test]
2324 async fn attachment_download_state_is_monotonic_across_resaves() {
2325 let (_tmp, _guard) = init_test_db();
2326 let att = |id: &str, downloaded: bool, path: &str| Attachment {
2327 id: id.into(), url: "u".into(), name: "f.png".into(), extension: "png".into(),
2328 size: 1, downloaded, path: path.into(), ..Default::default()
2329 };
2330 let msg = |atts: Vec<Attachment>| Message {
2331 id: "dl1".into(), content: String::new(), at: 1_000_000, mine: false,
2332 npub: Some("npub1s".into()), attachments: atts, ..Default::default()
2333 };
2334
2335 save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2337 crate::db::attachments::set_attachment_downloaded("dl1", "nonceid", true, "/tmp/f.png").unwrap();
2338
2339 save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2341 let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2342 assert!(got[0].downloaded && got[0].path == "/tmp/f.png", "re-delivery preserves the download");
2343
2344 save_message("npub1dl", &msg(vec![att("contenthash", true, "/tmp/f.png")])).await.unwrap();
2346 let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2347 assert_eq!(got[0].id, "contenthash", "hash rewritten nonce→content");
2348 assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2349
2350 save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2353 let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2354 assert_eq!(got[0].id, "contenthash", "content-hash key survives a later re-delivery");
2355 assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2356 }
2357
2358 #[tokio::test]
2361 async fn attachments_fall_back_to_legacy_tag_when_table_empty() {
2362 let (_tmp, _guard) = init_test_db();
2363 let a = Attachment {
2364 id: "tagonly".into(), url: "u".into(), name: "old.png".into(), extension: "png".into(),
2365 size: 7, downloaded: false, ..Default::default()
2366 };
2367 save_message("npub1old", &Message {
2368 id: "old1".into(), content: String::new(), at: 2_000_000, mine: false,
2369 npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2370 }).await.unwrap();
2371
2372 {
2374 let conn = crate::db::get_write_connection_guard_static().unwrap();
2375 conn.execute("DELETE FROM attachments WHERE event_id='old1'", []).unwrap();
2376 let inner = serde_json::to_string(&vec![a]).unwrap();
2377 let tags = serde_json::to_string(&vec![vec!["attachments".to_string(), inner]]).unwrap();
2378 conn.execute("UPDATE events SET tags=?1 WHERE id='old1'", rusqlite::params![tags]).unwrap();
2379 }
2380 assert!(crate::db::attachments::get_attachments_for_event("old1").unwrap().is_empty(), "table empty");
2381
2382 let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1old").unwrap();
2383 let views = get_message_views(chat_int, 10, 0).await.unwrap();
2384 let old = views.iter().find(|m| m.id == "old1").unwrap();
2385 assert_eq!(old.attachments.len(), 1, "attachment served from the legacy-tag fallback");
2386 assert_eq!(old.attachments[0].name, "old.png");
2387 }
2388
2389 #[tokio::test]
2392 async fn attachment_tag_strip_is_gated_on_backfill() {
2393 let (_tmp, _guard) = init_test_db();
2394 let a = Attachment { id: "h1".into(), extension: "png".into(), name: "f.png".into(), downloaded: false, ..Default::default() };
2395 let mk = |id: &str, secs: u64| Message {
2396 id: id.into(), content: String::new(), at: secs * 1000, mine: false,
2397 npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2398 };
2399 save_message("npub1s", &mk("bf", 1000)).await.unwrap();
2400 save_message("npub1s", &mk("unbf", 2000)).await.unwrap();
2401
2402 {
2403 let conn = crate::db::get_write_connection_guard_static().unwrap();
2404 let inner = serde_json::to_string(&vec![a.clone()]).unwrap();
2405 let with_tag = |ms: &str| serde_json::to_string(&vec![
2406 vec!["ms".to_string(), ms.to_string()],
2407 vec!["attachments".to_string(), inner.clone()],
2408 ]).unwrap();
2409 conn.execute("UPDATE events SET tags=?1 WHERE id='bf'", rusqlite::params![with_tag("5")]).unwrap();
2411 conn.execute("UPDATE events SET tags=?1 WHERE id='unbf'", rusqlite::params![with_tag("6")]).unwrap();
2412 conn.execute("DELETE FROM attachments WHERE event_id='unbf'", []).unwrap();
2413
2414 let events: Vec<(String, String)> = {
2416 let mut stmt = conn.prepare("SELECT id, tags FROM events WHERE tags LIKE '%attachments%' AND id IN (SELECT DISTINCT event_id FROM attachments)").unwrap();
2417 let m = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).unwrap();
2418 m.flatten().collect()
2419 };
2420 for (id, tj) in events {
2421 let mut tags: Vec<Vec<String>> = serde_json::from_str(&tj).unwrap();
2422 tags.retain(|t| t.first().map(|s| s.as_str()) != Some("attachments"));
2423 conn.execute("UPDATE events SET tags=?1 WHERE id=?2",
2424 rusqlite::params![serde_json::to_string(&tags).unwrap(), id]).unwrap();
2425 }
2426
2427 let bf: String = conn.query_row("SELECT tags FROM events WHERE id='bf'", [], |r| r.get(0)).unwrap();
2428 assert!(!bf.contains("attachments"), "backfilled event's attachments tag stripped");
2429 assert!(bf.contains("\"ms\""), "sibling ms tag survives the strip");
2430 let unbf: String = conn.query_row("SELECT tags FROM events WHERE id='unbf'", [], |r| r.get(0)).unwrap();
2431 assert!(unbf.contains("attachments"), "un-backfilled event keeps its tag (no table row)");
2432 }
2433
2434 let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1s").unwrap();
2436 let views = get_message_views(chat_int, 10, 0).await.unwrap();
2437 assert_eq!(views.iter().find(|m| m.id == "unbf").unwrap().attachments.len(), 1, "fallback still renders unbf");
2438 }
2439
2440 #[tokio::test]
2444 async fn compute_unread_anchor_covers_the_cases() {
2445 let (_tmp, _guard) = init_test_db();
2446 let mk = |id: &str, secs: u64, mine: bool| Message {
2447 id: id.into(), content: "x".into(), at: secs * 1000, mine,
2448 npub: (!mine).then(|| "npub1sender".to_string()),
2449 ..Default::default()
2450 };
2451 let unread = |chat: &'static str| async move {
2452 unread_counts().await.unwrap().get(chat).copied().unwrap_or(0)
2453 };
2454 let set_lr = |chat: &str, lr: &str| {
2455 let conn = crate::db::get_write_connection_guard_static().unwrap();
2456 conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2457 rusqlite::params![lr, chat]).unwrap();
2458 };
2459
2460 let a = "npub1anchorA";
2463 save_message(a, &mk("a_mine", 1000, true)).await.unwrap();
2464 for i in 0..8u64 { save_message(a, &mk(&format!("a{i}"), 2000 + i, false)).await.unwrap(); }
2465 assert_eq!(compute_unread_anchor(a).await.unwrap(), UnreadMark::Anchor("a6".into()));
2466 set_lr(a, "a6");
2467 assert_eq!(unread(a).await, 1, "A: newest contact message is the sole unread");
2468
2469 let b = "npub1anchorB";
2471 save_message(b, &mk("b0", 2000, false)).await.unwrap();
2472 save_message(b, &mk("b_mine", 2001, true)).await.unwrap();
2473 assert_eq!(compute_unread_anchor(b).await.unwrap(), UnreadMark::NoOp);
2474
2475 let c = "npub1anchorC";
2478 save_message(c, &mk("c0", 3000, false)).await.unwrap();
2479 save_message(c, &mk("c1", 3005, false)).await.unwrap();
2480 save_message(c, &mk("c2", 3005, false)).await.unwrap();
2481 assert_eq!(compute_unread_anchor(c).await.unwrap(), UnreadMark::Anchor("c0".into()));
2482 set_lr(c, "c0");
2483 assert_eq!(unread(c).await, 2, "C: same-second tail both count");
2484
2485 let d = "npub1anchorD";
2487 save_message(d, &mk("d0", 4000, false)).await.unwrap();
2488 assert_eq!(compute_unread_anchor(d).await.unwrap(), UnreadMark::Clear);
2489 set_lr(d, "");
2490 assert_eq!(unread(d).await, 1, "D: lone contact message surfaces");
2491
2492 let e = "npub1anchorE";
2494 save_message(e, &mk("e_mine", 5000, true)).await.unwrap();
2495 assert_eq!(compute_unread_anchor(e).await.unwrap(), UnreadMark::NoOp);
2496 }
2497
2498 #[tokio::test]
2506 async fn a_system_event_with_a_blank_conversation_id_is_refused() {
2507 let (_tmp, _guard) = init_test_db();
2508 let before = chat_row_count();
2509 for blank in ["", " "] {
2510 assert!(
2511 save_system_event_at("ev", blank, SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2512 .await
2513 .is_err(),
2514 "a blank conversation id must be refused, not minted"
2515 );
2516 }
2517 assert_eq!(chat_row_count(), before, "and no chat row is created");
2518 assert!(
2520 save_system_event_at("ev2", "real-chat", SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2521 .await
2522 .unwrap()
2523 );
2524 }
2525
2526 fn chat_row_count() -> i64 {
2527 let conn = crate::db::get_db_connection_guard_static().unwrap();
2528 conn.query_row("SELECT count(*) FROM chats", [], |r| r.get(0)).unwrap()
2529 }
2530
2531 #[tokio::test]
2532 async fn unread_clears_when_last_read_is_a_system_event() {
2533 let (_tmp, _guard) = init_test_db();
2534 let chat = "npub1sysevtdm";
2535 let mk = |id: &str, secs: u64| Message {
2536 id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2537 npub: Some("npub1sender".to_string()), ..Default::default()
2538 };
2539 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2540
2541 for i in 0..5u64 {
2544 save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap();
2545 }
2546 assert_eq!(unread().await, 5, "never-read backlog");
2547
2548 save_system_event_at("sysev", chat, SystemEventType::MemberJoined, "npubX", None, 2000, None, None).await.unwrap();
2550
2551 {
2553 let conn = crate::db::get_write_connection_guard_static().unwrap();
2554 conn.execute(
2555 "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2556 rusqlite::params!["sysev", chat],
2557 ).unwrap();
2558 }
2559 assert_eq!(unread().await, 0, "read marker on a system event still clears the badge");
2560 }
2561
2562 #[tokio::test]
2566 async fn deleting_a_message_adjusts_unread_without_wedging() {
2567 let (_tmp, _guard) = init_test_db();
2568 let chat = "npub1delunread";
2569 let mk = |id: &str, secs: u64| Message {
2570 id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2571 npub: Some("npub1sender".to_string()), ..Default::default()
2572 };
2573 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2574 let set_marker = |id: &str| {
2575 let conn = crate::db::get_write_connection_guard_static().unwrap();
2576 conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2577 rusqlite::params![id, chat]).unwrap();
2578 };
2579 let marker = || -> String {
2580 let conn = crate::db::get_db_connection_guard_static().unwrap();
2581 conn.query_row("SELECT last_read FROM chats WHERE chat_identifier = ?1",
2582 rusqlite::params![chat], |r| r.get::<_, String>(0)).unwrap()
2583 };
2584
2585 for i in 0..6u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap(); }
2587 set_marker("m1");
2588 assert_eq!(unread().await, 4, "m2..m5 unread");
2589
2590 delete_event("m3").await.unwrap();
2592 assert_eq!(unread().await, 3, "one unread deleted → badge minus one");
2593 assert_eq!(marker(), "m1", "deleting an unread message leaves the marker alone");
2594
2595 delete_event("m1").await.unwrap();
2597 assert_eq!(marker(), "m0", "marker retreats to the newest survivor before it");
2598 assert_eq!(unread().await, 3, "retreat keeps the count, no collapse to 99+");
2599
2600 delete_event("m0").await.unwrap();
2602 assert_eq!(marker(), "", "no earlier survivor → marker clears");
2603 assert_eq!(unread().await, 3, "cleared marker counts only the true unread survivors");
2604 }
2605
2606 #[tokio::test]
2610 async fn edit_event_folds_into_history_on_reload() {
2611 let (_tmp, _guard) = init_test_db();
2612 let chat = "channel_edit_fold";
2613 save_message(chat, &Message {
2614 id: "orig1".into(), content: "original".into(), at: 5_000_000,
2615 npub: Some("npub1author".into()), ..Default::default()
2616 }).await.unwrap();
2617
2618 let cid = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2619 let emoji = vec![crate::types::EmojiTag { shortcode: "wave".into(), url: "u/wave".into() }];
2620 save_edit_event("edit1", "orig1", "edited :wave:", &emoji, cid, None, "npub1author").await.unwrap();
2621
2622 let m = get_message_views(cid, 50, 0).await.unwrap()
2623 .into_iter().find(|m| m.id == "orig1").expect("message reloaded");
2624 assert!(m.edited, "folded edit sets the edited flag");
2625 let h = m.edit_history.as_ref().expect("history reconstructed from the edit event");
2626 assert_eq!(h.len(), 2, "original + one edit");
2627 assert_eq!(h[0].content, "original");
2628 assert_eq!(h[1].content, "edited :wave:");
2629 assert_eq!(m.content, "edited :wave:", "latest revision is the displayed content");
2630 assert_eq!(m.emoji_tags.len(), 1, "the edit's own emoji folds onto the message");
2631 assert_eq!(m.emoji_tags[0].shortcode, "wave");
2632 }
2633
2634 #[tokio::test]
2638 async fn batched_save_matches_single_save_shape() {
2639 let (_tmp, _guard) = init_test_db();
2640 let chat = "channel_batch1";
2641 let att = crate::types::Attachment {
2642 id: "atthash1".into(), extension: "png".into(), name: "a.png".into(),
2643 url: "https://x/att".into(), downloaded: false, ..Default::default()
2644 };
2645 let reaction = Reaction {
2646 id: "react_b1".into(), reference_id: "b1".into(),
2647 author_id: "npub1reactor".into(), emoji: "👍".into(), emoji_url: None,
2648 };
2649 let msgs: Vec<Message> = (0..5u64).map(|i| Message {
2651 id: format!("b{i}"), content: format!("c{i}"), at: 7_000_000,
2652 npub: Some("npub1sender".into()),
2653 attachments: if i == 2 { vec![att.clone()] } else { Vec::new() },
2654 reactions: if i == 1 { vec![reaction.clone()] } else { Vec::new() },
2655 ..Default::default()
2656 }).collect();
2657 let refs: Vec<&Message> = msgs.iter().collect();
2658
2659 let saved = save_messages_batch(chat, &refs).await.unwrap();
2660 assert_eq!(saved, 5, "every message written");
2661
2662 for i in 0..5u64 {
2663 assert!(event_exists(&format!("b{i}")).unwrap(), "b{i} row exists");
2664 }
2665 assert!(event_exists("react_b1").unwrap(), "reaction landed as its own kind-7 row");
2666 let atts = crate::db::attachments::get_attachments_for_event("b2").unwrap();
2667 assert_eq!(atts.len(), 1, "attachment row committed with its event");
2668 assert_eq!(atts[0].id, "atthash1");
2669
2670 let conn = crate::db::get_db_connection_guard_static().unwrap();
2672 let ids: Vec<String> = conn
2673 .prepare("SELECT id FROM events WHERE id IN ('b0','b1','b2','b3','b4') ORDER BY rowid")
2674 .unwrap()
2675 .query_map([], |r| r.get(0)).unwrap()
2676 .flatten().collect();
2677 assert_eq!(ids, vec!["b0", "b1", "b2", "b3", "b4"], "insert order preserves the rowid tiebreak");
2678 }
2679
2680 fn link_channel_to_community(chat: &str, community: &str) {
2682 let conn = crate::db::get_write_connection_guard_static().unwrap();
2683 conn.execute(
2684 "INSERT OR REPLACE INTO community_channels \
2685 (channel_id, community_id, channel_key, epoch, name, created_at) \
2686 VALUES (?1, ?2, x'00', 0, 'general', 0)",
2687 rusqlite::params![chat, community],
2688 )
2689 .unwrap();
2690 }
2691
2692 fn ban_pair() -> (String, String) {
2695 let k = nostr_sdk::prelude::Keys::generate();
2696 use nostr_sdk::prelude::ToBech32;
2697 (k.public_key().to_hex(), k.public_key().to_bech32().unwrap())
2698 }
2699
2700 #[tokio::test]
2702 async fn a_banned_authors_messages_are_not_returned() {
2703 let (_tmp, _guard) = init_test_db();
2704 let chat = "channel_ban_hide";
2705 link_channel_to_community(chat, "comm_hide");
2706 let (raider_hex, raider) = ban_pair();
2707 let (_, good) = ban_pair();
2708 for (id, npub, at) in [("keep1", &good, 1_000u64), ("spam1", &raider, 2_000), ("keep2", &good, 3_000)] {
2709 let msg = Message { id: id.into(), content: "hi".into(), at, npub: Some(npub.clone()), ..Default::default() };
2710 save_message(chat, &msg).await.unwrap();
2711 }
2712 crate::db::community::set_community_banlist("comm_hide", &[raider_hex], 1).unwrap();
2713
2714 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2715 let got = get_events(chat_id, None, 50, 0).await.unwrap();
2716 let ids: Vec<&str> = got.iter().map(|e| e.id.as_str()).collect();
2717 assert!(!ids.contains(&"spam1"), "a banned author's existing message must not be returned: {ids:?}");
2718 assert!(ids.contains(&"keep1") && ids.contains(&"keep2"), "everyone else survives: {ids:?}");
2719 }
2720
2721 #[tokio::test]
2725 async fn a_wall_of_banned_events_does_not_shorten_the_page() {
2726 let (_tmp, _guard) = init_test_db();
2727 let chat = "channel_ban_page";
2728 link_channel_to_community(chat, "comm_page");
2729 let (raider_hex, raider) = ban_pair();
2732 let (_, good) = ban_pair();
2733 for i in 0..20u64 {
2734 let msg = Message { id: format!("real{i}"), content: "hi".into(), at: 1_000 + i, npub: Some(good.clone()), ..Default::default() };
2735 save_message(chat, &msg).await.unwrap();
2736 }
2737 for i in 0..40u64 {
2738 let msg = Message { id: format!("spam{i}"), content: "buy".into(), at: 5_000 + i, npub: Some(raider.clone()), ..Default::default() };
2739 save_message(chat, &msg).await.unwrap();
2740 }
2741 crate::db::community::set_community_banlist("comm_page", &[raider_hex], 1).unwrap();
2742
2743 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2744 let page = get_events(chat_id, None, 20, 0).await.unwrap();
2745 assert_eq!(page.len(), 20, "a page of 20 must return 20 real messages, not what is left after filtering");
2746 assert!(page.iter().all(|e| e.npub.as_deref() == Some(good.as_str())), "no banned author leaks into the page");
2747 }
2748
2749 #[tokio::test]
2752 async fn the_kind_filtered_page_excludes_banned_authors_too() {
2753 let (_tmp, _guard) = init_test_db();
2754 let chat = "channel_ban_kinds";
2755 link_channel_to_community(chat, "comm_kinds");
2756 let (raider_hex, raider) = ban_pair();
2757 let (_, good) = ban_pair();
2758 for i in 0..12u64 {
2759 let msg = Message { id: format!("real{i}"), content: "hi".into(), at: 1_000 + i, npub: Some(good.clone()), ..Default::default() };
2760 save_message(chat, &msg).await.unwrap();
2761 }
2762 for i in 0..30u64 {
2763 let msg = Message { id: format!("spam{i}"), content: "buy".into(), at: 5_000 + i, npub: Some(raider.clone()), ..Default::default() };
2764 save_message(chat, &msg).await.unwrap();
2765 }
2766 crate::db::community::set_community_banlist("comm_kinds", &[raider_hex], 1).unwrap();
2767
2768 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2769 let kinds = [event_kind::PRIVATE_DIRECT_MESSAGE];
2770 let page = get_events(chat_id, Some(&kinds), 12, 0).await.unwrap();
2771 assert_eq!(page.len(), 12, "the kind-filtered page must fill with real messages");
2772 assert!(page.iter().all(|e| e.npub.as_deref() == Some(good.as_str())), "no banned author in a kind-filtered page");
2773 }
2774
2775 #[tokio::test]
2779 async fn a_banned_members_join_event_is_hidden_with_their_messages() {
2780 let (_tmp, _guard) = init_test_db();
2781 let chat = "channel_ban_join";
2782 link_channel_to_community(chat, "comm_join");
2783 let (raider_hex, raider) = ban_pair();
2784
2785 save_system_event_at("joined_evt", chat, SystemEventType::MemberJoined, &raider, None, 4_000, None, None)
2786 .await
2787 .unwrap();
2788 let msg = Message { id: "spam_after_join".into(), content: "buy".into(), at: 5_000, npub: Some(raider.clone()), ..Default::default() };
2789 save_message(chat, &msg).await.unwrap();
2790 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2791 assert_eq!(get_events(chat_id, None, 50, 0).await.unwrap().len(), 2, "both rows are on record first");
2793
2794 crate::db::community::set_community_banlist("comm_join", &[raider_hex], 1).unwrap();
2795
2796 let got = get_events(chat_id, None, 50, 0).await.unwrap();
2797 let ids: Vec<&str> = got.iter().map(|e| e.id.as_str()).collect();
2798 assert!(!ids.contains(&"joined_evt"), "the join row goes with them: {ids:?}");
2799 assert!(!ids.contains(&"spam_after_join"), "and so does what they said: {ids:?}");
2800 }
2801
2802 #[tokio::test]
2805 async fn a_banned_authors_messages_do_not_badge_the_channel() {
2806 let (_tmp, _guard) = init_test_db();
2807 let chat = "channel_ban_unread";
2808 link_channel_to_community(chat, "comm_unread");
2809 let (raider_hex, raider) = ban_pair();
2810 let (_, good) = ban_pair();
2811
2812 let real = Message { id: "real_unread".into(), content: "hi".into(), at: 1_000, npub: Some(good.clone()), ..Default::default() };
2813 save_message(chat, &real).await.unwrap();
2814 for i in 0..5u64 {
2815 let spam = Message { id: format!("spam_unread{i}"), content: "buy".into(), at: 2_000 + i, npub: Some(raider.clone()), ..Default::default() };
2816 save_message(chat, &spam).await.unwrap();
2817 }
2818 assert_eq!(unread_count_for_chat(chat).await.unwrap(), 6, "before the ban everything counts");
2819
2820 crate::db::community::set_community_banlist("comm_unread", &[raider_hex], 1).unwrap();
2821
2822 assert_eq!(
2823 unread_count_for_chat(chat).await.unwrap(), 1,
2824 "only the message that still renders is counted"
2825 );
2826 let all = unread_counts().await.unwrap();
2827 assert_eq!(all.get(chat).copied().unwrap_or(0), 1, "and the all-chats sweep agrees");
2828 }
2829
2830 #[tokio::test]
2834 async fn a_banned_members_join_line_is_dropped_from_the_system_feed() {
2835 let (_tmp, _guard) = init_test_db();
2836 let chat = "channel_ban_sysfeed";
2837 link_channel_to_community(chat, "comm_sysfeed");
2838 let (raider_hex, raider) = ban_pair();
2839 let (_, good) = ban_pair();
2840
2841 for (id, who) in [("join_good", &good), ("join_raider", &raider)] {
2842 save_system_event_at(id, chat, SystemEventType::MemberJoined, who, None, 1_000, None, None)
2843 .await
2844 .unwrap();
2845 }
2846 assert_eq!(get_system_events_for_chat(chat).unwrap().len(), 2, "both arrivals are on record");
2847
2848 crate::db::community::set_community_banlist("comm_sysfeed", &[raider_hex], 1).unwrap();
2849
2850 let feed = get_system_events_for_chat(chat).unwrap();
2851 let ids: Vec<&str> = feed.iter().map(|e| e.id.as_str()).collect();
2852 assert_eq!(ids, vec!["join_good"], "the banned member's arrival goes with their messages");
2853 }
2854
2855 #[tokio::test]
2863 async fn a_moderated_delete_survives_the_message_being_re_ingested() {
2864 let (_tmp, _guard) = init_test_db();
2865 let chat = "channel_delete_resync";
2866 let (_, author) = ban_pair();
2867 let msg = Message { id: "moderated_evt".into(), content: "nsfw".into(), at: 1_000, npub: Some(author.clone()), ..Default::default() };
2868 save_message(chat, &msg).await.unwrap();
2869
2870 crate::state::note_message_deleted("moderated_evt");
2872 add_message_tombstone("moderated_evt").unwrap();
2873 delete_event("moderated_evt").await.unwrap();
2874
2875 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2876 assert!(get_events(chat_id, None, 50, 0).await.unwrap().is_empty(), "removed on the spot");
2877
2878 assert!(
2880 crate::state::was_message_deleted("moderated_evt"),
2881 "the refusal outlives the row, so the re-ingest is dropped rather than resurrecting it"
2882 );
2883 }
2884
2885 #[tokio::test]
2889 async fn the_backscroll_window_excludes_banned_authors() {
2890 let (_tmp, _guard) = init_test_db();
2891 let chat = "channel_ban_window";
2892 link_channel_to_community(chat, "comm_window");
2893 let (raider_hex, raider) = ban_pair();
2894 let (_, good) = ban_pair();
2895
2896 for i in 0..10u64 {
2897 let m = Message { id: format!("w_real{i}"), content: "hi".into(), at: 1_000 + i, npub: Some(good.clone()), ..Default::default() };
2898 save_message(chat, &m).await.unwrap();
2899 let s = Message { id: format!("w_spam{i}"), content: "buy".into(), at: 1_500 + i, npub: Some(raider.clone()), ..Default::default() };
2900 save_message(chat, &s).await.unwrap();
2901 }
2902 crate::db::community::set_community_banlist("comm_window", &[raider_hex], 1).unwrap();
2903
2904 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2905 let win = get_messages_around(chat_id, "w_real5", 20, 20).await.unwrap();
2906 assert!(!win.is_empty(), "the window still returns the real messages");
2907 assert!(
2908 win.iter().all(|m| m.npub.as_deref() == Some(good.as_str())),
2909 "no banned author reachable by jumping to a message near them",
2910 );
2911 }
2912
2913 #[tokio::test]
2915 async fn a_dm_is_unaffected_by_the_ban_filter() {
2916 let (_tmp, _guard) = init_test_db();
2917 let chat = "npub1peer_dm";
2918 let msg = Message { id: "dm1".into(), content: "hi".into(), at: 1_000, npub: Some("npub1peer".into()), ..Default::default() };
2919 save_message(chat, &msg).await.unwrap();
2920 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2921 assert_eq!(get_events(chat_id, None, 50, 0).await.unwrap().len(), 1, "a DM resolves no community and filters nothing");
2922 }
2923
2924 #[tokio::test]
2927 async fn batched_resave_preserves_wrapper_and_dedups_reactions() {
2928 let (_tmp, _guard) = init_test_db();
2929 let chat = "channel_batch2";
2930 let reaction = Reaction {
2931 id: "react_rs".into(), reference_id: "rs1".into(),
2932 author_id: "npub1reactor".into(), emoji: "🔥".into(), emoji_url: None,
2933 };
2934 let mut msg = Message {
2935 id: "rs1".into(), content: "hello".into(), at: 8_000_000,
2936 npub: Some("npub1sender".into()),
2937 wrapper_event_id: Some("wrap_original".into()),
2938 reactions: vec![reaction],
2939 ..Default::default()
2940 };
2941 save_message(chat, &msg).await.unwrap();
2942
2943 msg.wrapper_event_id = None;
2945 let saved = save_messages_batch(chat, &[&msg]).await.unwrap();
2946 assert_eq!(saved, 1);
2947
2948 let conn = crate::db::get_db_connection_guard_static().unwrap();
2949 let wrapper: Option<String> = conn.query_row(
2950 "SELECT wrapper_event_id FROM events WHERE id = 'rs1'", [], |r| r.get(0),
2951 ).unwrap();
2952 assert_eq!(wrapper.as_deref(), Some("wrap_original"), "COALESCE keeps the known wrapper");
2953 let reaction_rows: i64 = conn.query_row(
2954 "SELECT COUNT(*) FROM events WHERE id = 'react_rs'", [], |r| r.get(0),
2955 ).unwrap();
2956 assert_eq!(reaction_rows, 1, "reaction row not duplicated by the re-save");
2957 }
2958
2959 #[tokio::test]
2963 async fn batching_persist_flushes_multi_chat_and_drains_into_the_account_that_filled_it() {
2964 let (_tmp, _guard) = init_test_db();
2965 let handler = crate::event_handler::NoOpEventHandler;
2966 let batcher = crate::event_handler::BatchingPersist::new(&handler);
2967
2968 let mk = |id: &str, npub: &str| Message {
2969 id: id.into(), content: "x".into(), at: 9_000_000,
2970 npub: Some(npub.into()), ..Default::default()
2971 };
2972 let seed = |chat: &str, m: &Message| {
2975 let m = m.clone();
2976 let chat = chat.to_string();
2977 async move {
2978 let mut st = crate::state::STATE.lock().await;
2979 st.add_message_to_participant(&chat, &m);
2980 }
2981 };
2982 let wrap_a1 = ([0xA1u8; 32], 111u64);
2983 let wrap_b1 = ([0xB1u8; 32], 222u64);
2984 let a1 = mk("bp_a1", "npub1chata");
2985 let b1 = mk("bp_b1", "npub1chatb");
2986 let a2 = mk("bp_a2", "npub1chata");
2987 seed("npub1chata", &a1).await;
2988 seed("npub1chatb", &b1).await;
2989 seed("npub1chata", &a2).await;
2990
2991 use crate::event_handler::InboundEventHandler;
2992 assert!(batcher.buffer_persist("npub1chata", &a1, Some(wrap_a1)), "batcher owns the persist");
2993 assert!(batcher.buffer_persist("npub1chatb", &b1, Some(wrap_b1)));
2994 assert!(batcher.buffer_persist("npub1chata", &a2, None));
2995 assert_eq!(batcher.buffered(), 3);
2996
2997 let ledgered = |bytes: [u8; 32]| {
2998 let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2999 crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
3000 };
3001 assert!(!ledgered(wrap_a1.0), "wrapper unledgered while its message sits buffered");
3002
3003 assert_eq!(batcher.flush().await, 3, "all buffered messages written");
3004 assert_eq!(batcher.buffered(), 0);
3005 assert!(event_exists("bp_a1").unwrap() && event_exists("bp_b1").unwrap() && event_exists("bp_a2").unwrap());
3006 assert!(ledgered(wrap_a1.0) && ledgered(wrap_b1.0), "wrappers ledgered with the flush");
3007 let a = crate::db::id_cache::get_chat_id_by_identifier("npub1chata").unwrap();
3008 let b = crate::db::id_cache::get_chat_id_by_identifier("npub1chatb").unwrap();
3009 assert_ne!(a, b, "rows grouped under their own chats");
3010
3011 let filled_under = crate::db::current_session();
3015 let late = mk("bp_late", "npub1chata");
3016 seed("npub1chata", &late).await;
3017 let wrap_late = ([0x5Eu8; 32], 333u64);
3018 batcher.buffer_persist("npub1chata", &late, Some(wrap_late));
3019
3020 let next = make_test_npub(TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
3021 std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&next)).unwrap();
3022 crate::db::set_current_account(next.clone()).unwrap();
3023 crate::db::init_database(&next).unwrap();
3024
3025 assert_eq!(batcher.flush().await, 1, "the buffer still drains");
3026 assert!(
3027 crate::db::with_session(filled_under, async {
3028 event_exists("bp_late").unwrap() && ledgered(wrap_late.0)
3029 })
3030 .await,
3031 "written to the account that received it, and its wrapper ledgered there"
3032 );
3033 assert!(!event_exists("bp_late").unwrap(), "the account swapped in never sees it");
3034 assert_eq!(batcher.buffered(), 0, "and the buffer is empty either way");
3035 }
3036
3037 #[tokio::test]
3044 async fn a_delete_tombstone_survives_a_restart() {
3045 let (_tmp, _guard) = init_test_db();
3049 let msg = Message {
3050 id: "resurrect-me".into(), content: "note".into(), at: 1_000_000,
3051 mine: true, ..Default::default()
3052 };
3053 save_message("npub1notes", &msg).await.unwrap();
3054 add_message_tombstone("resurrect-me").unwrap();
3055 delete_event("resurrect-me").await.unwrap();
3056
3057 crate::db::close_database();
3059 assert!(!crate::state::was_message_deleted("resurrect-me"), "session set cleared");
3060 crate::state::seed_message_tombstones(load_message_tombstones().unwrap());
3062 assert!(crate::state::was_message_deleted("resurrect-me"), "the durable tombstone re-seeds");
3063 }
3064
3065 #[tokio::test]
3066 async fn buffered_message_deleted_before_flush_never_persists() {
3067 let (_tmp, _guard) = init_test_db();
3068 let handler = crate::event_handler::NoOpEventHandler;
3069 let batcher = crate::event_handler::BatchingPersist::new(&handler);
3070 use crate::event_handler::InboundEventHandler;
3071
3072 let chat = "npub1delchat";
3073 let mk = |id: &str| Message {
3074 id: id.into(), content: "x".into(), at: 9_500_000,
3075 npub: Some(chat.into()), ..Default::default()
3076 };
3077 let ledgered = |bytes: [u8; 32]| {
3078 let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
3079 crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
3080 };
3081
3082 let m1 = mk("del_sametask");
3085 {
3086 let mut st = crate::state::STATE.lock().await;
3087 st.add_message_to_participant(chat, &m1);
3088 }
3089 batcher.buffer_persist(chat, &m1, Some(([0xD1u8; 32], 444)));
3090 {
3091 let mut st = crate::state::STATE.lock().await;
3092 st.remove_message("del_sametask");
3093 }
3094 batcher.on_message_deleted(chat, "del_sametask");
3095 assert_eq!(batcher.buffered(), 0, "deletion purges the buffered target");
3096
3097 let m2 = mk("del_crosstask");
3100 {
3101 let mut st = crate::state::STATE.lock().await;
3102 st.add_message_to_participant(chat, &m2);
3103 }
3104 batcher.buffer_persist(chat, &m2, Some(([0xD2u8; 32], 555)));
3105 crate::state::note_message_deleted("del_crosstask");
3106
3107 let m3 = mk("evicted_ok");
3109 let wrap_evicted = ([0xE0u8; 32], 666u64);
3110 {
3111 let mut st = crate::state::STATE.lock().await;
3112 st.add_message_to_participant(chat, &m3);
3113 st.remove_message("evicted_ok");
3114 }
3115 batcher.buffer_persist(chat, &m3, Some(wrap_evicted));
3116
3117 assert_eq!(batcher.flush().await, 1, "tombstoned target dropped, evicted message written");
3118 assert!(!event_exists("del_sametask").unwrap(), "purged message never persisted");
3119 assert!(!event_exists("del_crosstask").unwrap(), "tombstoned message never persisted");
3120 assert!(event_exists("evicted_ok").unwrap(), "evicted-but-not-deleted message persisted");
3121 assert!(ledgered(wrap_evicted.0), "evicted message's wrapper ledgered with it");
3122 }
3123}
3124
3125pub fn get_event_wrap_context(event_id: &str) -> Result<Option<(Option<String>, Vec<Vec<String>>)>, String> {
3128 let conn = super::get_db_connection_guard_static()?;
3129 let row: Option<(Option<String>, String)> = conn
3130 .query_row(
3131 "SELECT wrapper_event_id, tags FROM events WHERE id = ?1",
3132 rusqlite::params![event_id],
3133 |r| Ok((r.get(0)?, r.get(1)?)),
3134 )
3135 .optional()
3136 .map_err(|e| format!("get wrap context: {e}"))?;
3137 Ok(row.map(|(wrap, tags_json)| {
3138 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
3139 (wrap, tags)
3140 }))
3141}