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: web_time::SystemTime::now()
131 .duration_since(web_time::UNIX_EPOCH)
132 .map(|d| d.as_secs()).unwrap_or(0),
133 received_at: web_time::SystemTime::now()
134 .duration_since(web_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
219pub(crate) type WrapperStamp = ([u8; 32], u64);
221pub(crate) type ChatBatch<'a> = (String, Vec<(&'a Message, Option<WrapperStamp>)>);
223
224async fn prepare_batch_rows<'a>(
228 chat_id: &str,
229 messages: &[(&'a Message, Option<WrapperStamp>)],
230 rows: &mut Vec<BatchRow<'a>>,
231) -> Result<(), String> {
232 let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
233 let my_npub = super::get_current_account();
234 for (message, wrapper) in messages {
235 let user_int_id = match &message.npub {
236 Some(npub_str) => super::id_cache::get_or_create_user_id(npub_str)?,
237 None => None,
238 };
239 let event = message_to_stored_event(message, chat_int_id, user_int_id);
240 let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
241 let content = encrypt_event_content(&event).await;
242 let mut reactions: Vec<(StoredEvent, String)> = Vec::with_capacity(message.reactions.len());
243 for reaction in &message.reactions {
244 let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
245 let is_mine = my_npub.as_deref().map(|n| reaction.author_id == n).unwrap_or(false);
246 let rev = reaction_to_stored_event(reaction, chat_int_id, user_id, is_mine, None);
247 let rtags = serde_json::to_string(&rev.tags).unwrap_or_else(|_| "[]".to_string());
248 reactions.push((rev, rtags));
249 }
250 rows.push(BatchRow { message, event, content, tags_json, reactions, wrapper: *wrapper });
251 }
252 Ok(())
253}
254
255fn write_batch_rows(rows: &[BatchRow<'_>]) -> Result<usize, String> {
261 let conn = super::get_write_connection_guard_static()?;
262 let tx = conn.unchecked_transaction().map_err(|e| format!("batch tx: {e}"))?;
263 let mut saved = 0usize;
264 for row in rows {
265 tx.execute_batch("SAVEPOINT batch_row").map_err(|e| format!("batch savepoint: {e}"))?;
270 let row_written = insert_event_row(&tx, &row.event, &row.content, &row.tags_json)
271 .and_then(|_| super::attachments::insert_attachment_rows(&tx, &row.message.id, &row.message.attachments));
272 if let Err(e) = row_written {
273 crate::log_warn!("[DB] batch skip {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
274 let _ = tx.execute_batch("ROLLBACK TO batch_row; RELEASE batch_row");
275 continue;
276 }
277 saved += 1;
278 for (rev, rtags) in &row.reactions {
279 if event_exists_on(&tx, &rev.id).unwrap_or(true) {
282 continue;
283 }
284 if let Err(e) = insert_event_row(&tx, rev, &rev.content, rtags) {
285 crate::log_warn!("[DB] batch reaction {}: {}", &rev.id[..8.min(rev.id.len())], e);
286 }
287 }
288 if let Some((wrapper_id, wrapper_created_at)) = &row.wrapper {
290 let mut stmt = tx.prepare_cached(
291 "INSERT OR IGNORE INTO processed_wrappers (wrapper_id, wrapper_created_at, transport) VALUES (?1, ?2, ?3)",
292 ).map_err(|e| format!("prepare wrapper ledger: {e}"))?;
293 if let Err(e) = stmt.execute(rusqlite::params![
294 &wrapper_id[..], *wrapper_created_at as i64, super::wrappers::TRANSPORT_NIP17,
295 ]) {
296 crate::log_warn!("[DB] batch wrapper ledger {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
297 }
298 }
299 tx.execute_batch("RELEASE batch_row").map_err(|e| format!("batch release: {e}"))?;
300 }
301 tx.commit().map_err(|e| format!("batch commit: {e}"))?;
302 Ok(saved)
303}
304
305pub async fn save_messages_batch(
318 chat_id: &str,
319 messages: &[&Message],
320) -> Result<usize, String> {
321 crate::db::scoped(async move {
322 if messages.is_empty() {
323 return Ok(0);
324 }
325 let with_wrappers: Vec<(&Message, Option<WrapperStamp>)> =
326 messages.iter().map(|m| (*m, None)).collect();
327 let mut rows = Vec::with_capacity(messages.len());
328 prepare_batch_rows(chat_id, &with_wrappers, &mut rows).await?;
329 write_batch_rows(&rows)
330 })
331 .await
332}
333
334pub async fn save_messages_batch_multi(
339 groups: &[ChatBatch<'_>],
340) -> Result<usize, String> {
341 crate::db::scoped(async move {
342 let total: usize = groups.iter().map(|(_, m)| m.len()).sum();
343 if total == 0 {
344 return Ok(0);
345 }
346 let mut rows = Vec::with_capacity(total);
347 for (chat_id, messages) in groups {
348 prepare_batch_rows(chat_id, messages, &mut rows).await?;
349 }
350 write_batch_rows(&rows)
351 })
352 .await
353}
354
355fn message_to_stored_event(message: &Message, chat_id: i64, user_id: Option<i64>) -> StoredEvent {
357 let kind = if !message.attachments.is_empty() {
358 event_kind::FILE_ATTACHMENT
359 } else {
360 event_kind::PRIVATE_DIRECT_MESSAGE
361 };
362
363 let mut tags: Vec<Vec<String>> = Vec::new();
364
365 let ms = message.at % 1000;
367 if ms > 0 {
368 tags.push(vec!["ms".to_string(), ms.to_string()]);
369 }
370
371 if !message.replied_to.is_empty() {
373 tags.push(vec![
374 "e".to_string(),
375 message.replied_to.clone(),
376 "".to_string(),
377 "reply".to_string(),
378 ]);
379 }
380
381 for et in &message.emoji_tags {
386 tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
387 }
388
389 for npub in &message.addressed_bots {
392 tags.push(vec!["bot".to_string(), npub.clone()]);
393 }
394
395 if let Some(exp) = message.expiration {
398 tags.push(vec!["expiration".to_string(), exp.to_string()]);
399 }
400
401 let preview_metadata = message.preview_metadata.as_ref()
402 .and_then(|m| serde_json::to_string(m).ok());
403
404 StoredEvent {
405 id: message.id.clone(),
406 kind,
407 chat_id,
408 user_id,
409 content: message.content.clone(),
410 tags,
411 reference_id: None,
412 created_at: message.at / 1000,
413 received_at: web_time::SystemTime::now()
414 .duration_since(web_time::UNIX_EPOCH)
415 .map(|d| d.as_millis() as u64)
416 .unwrap_or(0),
417 mine: message.mine,
418 pending: message.pending,
419 failed: message.failed,
420 wrapper_event_id: message.wrapper_event_id.clone(),
421 npub: message.npub.clone(),
422 preview_metadata,
423 }
424}
425
426pub async fn save_pivx_payment_event(
428 conversation_id: &str,
429 mut event: StoredEvent,
430) -> Result<(), String> {
431 event.chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
432 save_event(&event).await
433}
434
435pub async fn save_system_event_by_id(
438 event_id: &str,
439 conversation_id: &str,
440 event_type: crate::stored_event::SystemEventType,
441 member_npub: &str,
442 member_name: Option<&str>,
443) -> Result<bool, String> {
444 let now_secs = web_time::SystemTime::now()
445 .duration_since(web_time::UNIX_EPOCH)
446 .map(|d| d.as_secs()).unwrap_or(0);
447 save_system_event_at(event_id, conversation_id, event_type, member_npub, member_name, now_secs, None, None).await
448}
449
450#[allow(clippy::too_many_arguments)]
454pub async fn save_system_event_at(
455 event_id: &str,
456 conversation_id: &str,
457 event_type: crate::stored_event::SystemEventType,
458 member_npub: &str,
459 member_name: Option<&str>,
460 created_at_secs: u64,
461 invited_by: Option<&str>,
464 invited_label: Option<&str>,
465) -> Result<bool, String> {
466 if conversation_id.trim().is_empty() {
475 return Err("system event has no conversation id".to_string());
476 }
477 let chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
478
479 let now_secs = web_time::SystemTime::now()
480 .duration_since(web_time::UNIX_EPOCH)
481 .map(|d| d.as_secs()).unwrap_or(0);
482 let created_at = created_at_secs.min(now_secs);
484
485 let display_name = member_name.unwrap_or(member_npub);
486 let content = event_type.display_message(display_name);
487
488 let mut tags: Vec<Vec<String>> = vec![
489 vec!["d".to_string(), "system-event".to_string()],
490 vec!["event-type".to_string(), event_type.as_u8().to_string()],
491 vec!["member".to_string(), member_npub.to_string()],
492 ];
493 if let Some(by) = invited_by {
494 tags.push(vec!["invited-by".to_string(), by.to_string()]);
495 if let Some(l) = invited_label.filter(|l| !l.is_empty()) {
496 tags.push(vec!["invited-label".to_string(), l.to_string()]);
497 }
498 }
499 let tags_json = serde_json::to_string(&tags)
500 .map_err(|e| format!("Failed to serialize tags: {}", e))?;
501
502 let conn = super::get_write_connection_guard_static()?;
503 let rows = conn.execute(
504 r#"INSERT OR IGNORE INTO events (
505 id, kind, chat_id, user_id, content, tags, reference_id,
506 created_at, received_at, mine, pending, failed, wrapper_event_id, npub
507 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
508 rusqlite::params![
509 event_id,
510 event_kind::APPLICATION_SPECIFIC as i32,
511 chat_id, None::<i64>, content, tags_json, None::<String>,
512 created_at as i64, now_secs as i64,
513 0, 0, 0, None::<String>, member_npub,
514 ],
515 ).map_err(|e| format!("Failed to save system event: {}", e))?;
516
517 Ok(rows > 0)
518}
519
520pub async fn save_edit_event(
522 edit_id: &str,
523 message_id: &str,
524 new_content: &str,
525 emoji_tags: &[crate::types::EmojiTag],
526 chat_id: i64,
527 user_id: Option<i64>,
528 npub: &str,
529) -> Result<(), String> {
530 let now = web_time::SystemTime::now()
531 .duration_since(web_time::UNIX_EPOCH).unwrap();
532
533 let mut tags = vec![
536 vec!["e".to_string(), message_id.to_string(), "".to_string(), "edit".to_string()],
537 ];
538 for et in emoji_tags {
539 tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
540 }
541
542 let event = StoredEvent {
543 id: edit_id.to_string(),
544 kind: event_kind::MESSAGE_EDIT,
545 chat_id,
546 user_id,
547 content: new_content.to_string(),
548 tags,
549 reference_id: Some(message_id.to_string()),
550 created_at: now.as_secs(),
551 received_at: now.as_millis() as u64,
552 mine: true,
553 pending: false,
554 failed: false,
555 wrapper_event_id: None,
556 npub: Some(npub.to_string()),
557 preview_metadata: None,
558 };
559
560 save_event(&event).await
561}
562
563pub async fn delete_event(event_id: &str) -> Result<(), String> {
565 let conn = super::get_write_connection_guard_static()?;
566 if let Ok(Some((chat_row, at))) = conn.query_row(
570 "SELECT chat_id, created_at FROM events WHERE id = ?1",
571 rusqlite::params![event_id],
572 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
573 ).optional() {
574 conn.execute(
575 "UPDATE chats SET last_read = COALESCE(( \
576 SELECT id FROM events WHERE chat_id = ?1 AND id != ?2 AND created_at <= ?3 \
577 ORDER BY created_at DESC, id DESC LIMIT 1), '') \
578 WHERE id = ?1 AND last_read = ?2",
579 rusqlite::params![chat_row, event_id, at],
580 ).map_err(|e| format!("read-marker retreat: {e}"))?;
581 }
582 conn.execute(
583 "DELETE FROM events WHERE id = ?1",
584 rusqlite::params![event_id],
585 ).map_err(|e| format!("Failed to delete event: {}", e))?;
586 Ok(())
587}
588
589pub fn add_message_tombstone(event_id: &str) -> Result<(), String> {
593 let conn = super::get_write_connection_guard_static()?;
594 let now = web_time::SystemTime::now()
595 .duration_since(web_time::UNIX_EPOCH)
596 .map(|d| d.as_secs() as i64)
597 .unwrap_or(0);
598 conn.execute(
599 "INSERT OR IGNORE INTO deleted_messages (event_id, deleted_at) VALUES (?1, ?2)",
600 rusqlite::params![event_id, now],
601 ).map_err(|e| format!("Failed to save delete tombstone: {}", e))?;
602 Ok(())
603}
604
605pub fn load_message_tombstones() -> Result<Vec<String>, String> {
607 let conn = super::get_db_connection_guard_static()?;
608 let mut stmt = conn.prepare("SELECT event_id FROM deleted_messages")
609 .map_err(|e| format!("Failed to prepare tombstone query: {}", e))?;
610 let ids = stmt.query_map([], |row| row.get::<_, String>(0))
611 .map_err(|e| format!("Failed to query tombstones: {}", e))?
612 .filter_map(|r| r.ok())
613 .collect();
614 Ok(ids)
615}
616
617pub fn event_author(event_id: &str) -> Result<Option<String>, String> {
620 let conn = match super::get_db_connection_guard_static() {
621 Ok(c) => c,
622 Err(_) => return Ok(None),
623 };
624 conn.prepare_cached("SELECT npub FROM events WHERE id = ?1")
625 .and_then(|mut stmt| stmt.query_row(rusqlite::params![event_id], |row| row.get::<_, Option<String>>(0)))
626 .optional()
627 .map(|o| o.flatten())
628 .map_err(|e| format!("Failed to read event author: {}", e))
629}
630
631pub fn event_delete_context(event_id: &str) -> Result<Option<(String, bool, Option<String>)>, String> {
635 let conn = match super::get_db_connection_guard_static() {
636 Ok(c) => c,
637 Err(_) => return Ok(None),
638 };
639 conn.query_row(
640 "SELECT c.chat_identifier, e.mine, e.npub \
641 FROM events e JOIN chats c ON c.id = e.chat_id \
642 WHERE e.id = ?1",
643 rusqlite::params![event_id],
644 |row| {
645 Ok((
646 row.get::<_, String>(0)?,
647 row.get::<_, i32>(1)? != 0,
648 row.get::<_, Option<String>>(2)?,
649 ))
650 },
651 )
652 .optional()
653 .map_err(|e| format!("Failed to read event delete context: {}", e))
654}
655
656pub fn message_exists_in_db(message_id: &str) -> Result<bool, String> {
658 let conn = match super::get_db_connection_guard_static() {
659 Ok(c) => c,
660 Err(_) => return Ok(false),
661 };
662 conn.prepare_cached("SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)")
663 .and_then(|mut stmt| stmt.query_row(rusqlite::params![message_id], |row| row.get(0)))
664 .map_err(|e| format!("Failed to check event existence: {}", e))
665}
666
667pub fn wrapper_event_exists(wrapper_event_id: &str) -> Result<bool, String> {
669 let conn = match super::get_db_connection_guard_static() {
670 Ok(c) => c,
671 Err(_) => return Ok(false),
672 };
673 conn.prepare_cached("SELECT EXISTS(SELECT 1 FROM events WHERE wrapper_event_id = ?1)")
674 .and_then(|mut stmt| stmt.query_row(rusqlite::params![wrapper_event_id], |row| row.get(0)))
675 .map_err(|e| format!("Failed to check wrapper event existence: {}", e))
676}
677
678pub fn update_wrapper_event_id(event_id: &str, wrapper_event_id: &str) -> Result<bool, String> {
681 let conn = match super::get_write_connection_guard_static() {
682 Ok(c) => c,
683 Err(_) => return Ok(false),
684 };
685 let rows = conn.execute(
686 "UPDATE events SET wrapper_event_id = ?1 WHERE id = ?2 AND (wrapper_event_id IS NULL OR wrapper_event_id = '')",
687 rusqlite::params![wrapper_event_id, event_id],
688 ).map_err(|e| format!("Failed to update wrapper event ID: {}", e))?;
689 Ok(rows > 0)
690}
691
692pub fn get_chat_message_count(chat_id: i64) -> Result<usize, String> {
694 let conn = super::get_db_connection_guard_static()?;
695 let count: i64 = conn.query_row(
700 &format!(
701 "SELECT COUNT(*) FROM events WHERE chat_id = ?1 AND kind IN ({}, {}, {})",
702 event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT
703 ),
704 rusqlite::params![chat_id],
705 |row| row.get(0),
706 ).map_err(|e| format!("Failed to count messages: {}", e))?;
707 Ok(count as usize)
708}
709
710pub fn get_dm_contact_npubs() -> Result<Vec<String>, String> {
713 let conn = super::get_db_connection_guard_static()?;
714 let mut stmt = conn.prepare(&format!(
715 "SELECT c.chat_identifier FROM chats c \
716 WHERE c.chat_type = 0 AND EXISTS (\
717 SELECT 1 FROM events e WHERE e.chat_id = c.id AND e.mine = 1 AND e.kind IN ({}, {}))",
718 event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT
719 )).map_err(|e| format!("Failed to prepare DM contact query: {}", e))?;
720 let rows = stmt.query_map([], |row| row.get::<_, String>(0))
721 .map_err(|e| format!("Failed to query DM contacts: {}", e))?;
722 Ok(rows.filter_map(|r| r.ok()).collect())
723}
724
725pub fn get_pivx_payments_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
727 let conn = super::get_db_connection_guard_static()?;
728 let chat_id: i64 = conn.query_row(
729 "SELECT id FROM chats WHERE chat_identifier = ?1",
730 rusqlite::params![conversation_id], |row| row.get(0)
731 ).map_err(|_| "Chat not found")?;
732
733 let mut stmt = conn.prepare(
734 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
735 created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
736 FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
737 ).map_err(|e| format!("Failed to prepare: {}", e))?;
738
739 let rows = stmt.query_map(
740 rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
741 |row| {
742 let tags_json: String = row.get(5)?;
743 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
744 Ok(StoredEvent {
745 id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
746 chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
747 tags, reference_id: row.get(6)?,
748 created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
749 mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
750 failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
751 npub: row.get(13)?, preview_metadata: None,
752 })
753 }
754 ).map_err(|e| format!("Failed to query: {}", e))?;
755
756 let mut payments = Vec::new();
757 for row in rows {
758 let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
759 if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "pivx-payment") {
760 payments.push(event);
761 }
762 }
763 Ok(payments)
764}
765
766pub fn get_system_events_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
773 let conn = super::get_db_connection_guard_static()?;
774 let chat_id: i64 = conn.query_row(
775 "SELECT id FROM chats WHERE chat_identifier = ?1",
776 rusqlite::params![conversation_id], |row| row.get(0)
777 ).map_err(|_| "Chat not found")?;
778
779 let mut stmt = conn.prepare(
780 &format!("SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
781 created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
782 FROM events WHERE chat_id = ?1 AND kind = ?2{} ORDER BY created_at ASC, received_at ASC",
783 ban_filter_sql(3))
784 ).map_err(|e| format!("Failed to prepare: {}", e))?;
785
786 let rows = stmt.query_map(
787 rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32, community_of_chat(&conn, chat_id)],
788 |row| {
789 let tags_json: String = row.get(5)?;
790 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
791 Ok(StoredEvent {
792 id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
793 chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
794 tags, reference_id: row.get(6)?,
795 created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
796 mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
797 failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
798 npub: row.get(13)?, preview_metadata: None,
799 })
800 }
801 ).map_err(|e| format!("Failed to query: {}", e))?;
802
803 let mut events = Vec::new();
804 for row in rows {
805 let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
806 if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "system-event") {
807 events.push(event);
808 }
809 }
810 Ok(events)
811}
812
813fn parse_event_row(row: &rusqlite::Row) -> rusqlite::Result<StoredEvent> {
819 let tags_json: String = row.get(5)?;
820 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
821
822 Ok(StoredEvent {
823 id: row.get(0)?,
824 kind: row.get::<_, i32>(1)? as u16,
825 chat_id: row.get(2)?,
826 user_id: row.get(3)?,
827 content: row.get(4)?,
828 tags,
829 reference_id: row.get(6)?,
830 created_at: row.get::<_, i64>(7)? as u64,
831 received_at: row.get::<_, i64>(8)? as u64,
832 mine: row.get::<_, i32>(9)? != 0,
833 pending: row.get::<_, i32>(10)? != 0,
834 failed: row.get::<_, i32>(11)? != 0,
835 wrapper_event_id: row.get(12)?,
836 npub: row.get(13)?,
837 preview_metadata: row.get(14)?,
838 })
839}
840
841fn community_of_chat(conn: &rusqlite::Connection, chat_id: i64) -> Option<String> {
847 conn.prepare_cached(
848 "SELECT cc.community_id FROM community_channels cc \
849 JOIN chats c ON c.chat_identifier = cc.channel_id WHERE c.id = ?1",
850 )
851 .ok()?
852 .query_row(rusqlite::params![chat_id], |r| r.get::<_, String>(0))
853 .ok()
854}
855
856pub(super) fn id_list_param(ids: &[String]) -> String {
859 serde_json::to_string(ids).unwrap_or_else(|_| "[]".into())
860}
861
862fn ban_filter_sql(param: usize) -> String {
871 format!(
872 " AND (?{param} IS NULL OR events.npub IS NULL OR NOT EXISTS ( \
873 SELECT 1 FROM community_bans b \
874 WHERE b.community_id = ?{param} AND b.npub = events.npub))"
875 )
876}
877
878pub async fn get_events(
881 chat_id: i64,
882 kinds: Option<&[u16]>,
883 limit: usize,
884 offset: usize,
885) -> Result<Vec<StoredEvent>, String> {
886 let events: Vec<StoredEvent> = {
887 let conn = super::get_db_connection_guard_static()?;
888 let community = community_of_chat(&conn, chat_id);
889
890 if let Some(k) = kinds {
891 let kind_placeholders: String = (0..k.len())
892 .map(|i| format!("?{}", i + 2))
893 .collect::<Vec<_>>()
894 .join(",");
895 let limit_param = k.len() + 2;
896 let offset_param = k.len() + 3;
897 let community_param = k.len() + 4;
898
899 let sql = format!(
900 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
901 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
902 FROM events WHERE chat_id = ?1 AND kind IN ({}){} \
903 ORDER BY created_at DESC, received_at DESC \
904 LIMIT ?{} OFFSET ?{}",
905 kind_placeholders, ban_filter_sql(community_param), limit_param, offset_param
906 );
907
908 let mut stmt = conn.prepare_cached(&sql)
909 .map_err(|e| format!("Failed to prepare events query: {}", e))?;
910
911 match k.len() {
912 1 => {
913 let rows = stmt.query_map(
914 rusqlite::params![chat_id, k[0] as i32, limit as i64, offset as i64, community],
915 parse_event_row
916 ).map_err(|e| format!("Failed to query events: {}", e))?;
917 rows.filter_map(|r| r.ok()).collect()
918 },
919 2 => {
920 let rows = stmt.query_map(
921 rusqlite::params![chat_id, k[0] as i32, k[1] as i32, limit as i64, offset as i64, community],
922 parse_event_row
923 ).map_err(|e| format!("Failed to query events: {}", e))?;
924 rows.filter_map(|r| r.ok()).collect()
925 },
926 3 => {
927 let rows = stmt.query_map(
928 rusqlite::params![chat_id, k[0] as i32, k[1] as i32, k[2] as i32, limit as i64, offset as i64, community],
929 parse_event_row
930 ).map_err(|e| format!("Failed to query events: {}", e))?;
931 rows.filter_map(|r| r.ok()).collect()
932 },
933 _ => return Err("Unsupported number of kinds".to_string()),
934 }
935 } else {
936 let mut stmt = conn.prepare_cached(
937 &format!("SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
938 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
939 FROM events WHERE chat_id = ?1{} \
940 ORDER BY created_at DESC, received_at DESC \
941 LIMIT ?2 OFFSET ?3", ban_filter_sql(4))
942 ).map_err(|e| format!("Failed to prepare events query: {}", e))?;
943
944 let rows = stmt.query_map(
945 rusqlite::params![chat_id, limit as i64, offset as i64, community],
946 parse_event_row
947 ).map_err(|e| format!("Failed to query events: {}", e))?;
948 rows.filter_map(|r| r.ok()).collect()
949 }
950 };
951
952 let decrypted: Vec<StoredEvent> = crate::crypto::open_batch(|at_rest| events.into_iter().map(|mut event| {
954 if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
955 event.content = at_rest.open(event.content)
956 .unwrap_or_else(|_| "[Decryption failed]".to_string());
957 }
958 event
959 }).collect());
960
961 Ok(decrypted)
962}
963
964pub async fn get_related_events(
966 reference_ids: &[String],
967) -> Result<Vec<StoredEvent>, String> {
968 if reference_ids.is_empty() {
969 return Ok(Vec::new());
970 }
971
972 let conn = super::get_db_connection_guard_static()?;
973 let mut stmt = conn.prepare_cached(
974 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
975 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
976 FROM events WHERE reference_id IN (SELECT value FROM json_each(?1)) \
977 ORDER BY created_at ASC, received_at ASC",
978 )
979 .map_err(|e| format!("Failed to prepare related events query: {}", e))?;
980
981 let events: Vec<StoredEvent> = stmt.query_map([id_list_param(reference_ids)], parse_event_row)
982 .map_err(|e| format!("Failed to query related events: {}", e))?
983 .filter_map(|r| r.ok())
984 .collect();
985
986 Ok(events)
987}
988
989pub struct ReplyContext {
991 pub content: String,
992 pub npub: Option<String>,
993 pub has_attachment: bool,
994 pub extension: Option<String>,
997 pub emoji_tags: Vec<crate::types::EmojiTag>,
1000}
1001
1002pub async fn get_reply_contexts(
1004 message_ids: &[String],
1005) -> Result<std::collections::HashMap<String, ReplyContext>, String> {
1006 use std::collections::HashMap;
1007
1008 if message_ids.is_empty() {
1009 return Ok(HashMap::new());
1010 }
1011
1012 let (events, edits) = {
1013 let conn = super::get_db_connection_guard_static()?;
1014 let ids = id_list_param(message_ids);
1015
1016 let mut stmt = conn
1018 .prepare_cached("SELECT id, kind, content, npub, tags FROM events WHERE id IN (SELECT value FROM json_each(?1))")
1019 .map_err(|e| format!("Failed to prepare reply context query: {}", e))?;
1020
1021 let rows = stmt.query_map([&ids], |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 mut edit_stmt = conn
1031 .prepare_cached(
1032 "SELECT reference_id, content, tags FROM events \
1033 WHERE kind = ?2 AND reference_id IN (SELECT value FROM json_each(?1)) \
1034 ORDER BY created_at DESC, received_at DESC",
1035 )
1036 .map_err(|e| format!("Failed to prepare edit query: {}", e))?;
1037 let edit_rows = edit_stmt.query_map(rusqlite::params![&ids, event_kind::MESSAGE_EDIT as i32], |row| {
1038 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?,
1039 row.get::<_, Option<String>>(2)?))
1040 }).map_err(|e| format!("Failed to query edits: {}", e))?;
1041 let edits_result: Vec<_> = edit_rows.filter_map(|r| r.ok()).collect();
1042
1043 (events_result, edits_result)
1044 };
1045
1046 let mut latest_edits: HashMap<String, (String, Option<String>)> = HashMap::new();
1048 for (ref_id, content, tags) in edits {
1049 latest_edits.entry(ref_id).or_insert((content, tags));
1050 }
1051
1052 let file_ids: Vec<String> = events.iter()
1054 .filter(|(_, kind, _, _, _)| *kind == event_kind::FILE_ATTACHMENT as i32)
1055 .map(|(id, _, _, _, _)| id.clone())
1056 .collect();
1057 let atts_by_event = super::attachments::get_attachments_for_events(&file_ids).unwrap_or_default();
1058
1059 let mut contexts = HashMap::new();
1061 crate::crypto::open_batch(|at_rest| {
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 at_rest.open(content_to_decrypt)
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
1115 Ok(contexts)
1116}
1117
1118pub async fn populate_reply_contexts(messages: Vec<&mut Message>) -> Result<(), String> {
1125 let ids: Vec<String> = messages
1126 .iter()
1127 .filter(|m| !m.replied_to.is_empty())
1128 .map(|m| m.replied_to.clone())
1129 .collect();
1130 if ids.is_empty() {
1131 return Ok(());
1132 }
1133 let contexts = get_reply_contexts(&ids).await?;
1134 for message in messages {
1135 if let Some(ctx) = contexts.get(&message.replied_to) {
1136 message.replied_to_content = Some(ctx.content.clone());
1137 message.replied_to_npub = ctx.npub.clone();
1138 message.replied_to_has_attachment = Some(ctx.has_attachment);
1139 message.replied_to_attachment_extension = ctx.extension.clone();
1140 message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1141 }
1142 }
1143 Ok(())
1144}
1145
1146pub async fn populate_reply_context(message: &mut Message) -> Result<(), String> {
1149 if message.replied_to.is_empty() {
1150 return Ok(());
1151 }
1152
1153 let contexts = get_reply_contexts(std::slice::from_ref(&message.replied_to)).await?;
1154
1155 if let Some(ctx) = contexts.get(&message.replied_to) {
1156 message.replied_to_content = Some(ctx.content.clone());
1157 message.replied_to_npub = ctx.npub.clone();
1158 message.replied_to_has_attachment = Some(ctx.has_attachment);
1159 message.replied_to_attachment_extension = ctx.extension.clone();
1160 message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1161 }
1162
1163 Ok(())
1164}
1165
1166pub fn is_own_event(event_id: &str) -> bool {
1170 let Ok(conn) = super::get_db_connection_guard_static() else {
1171 return false;
1172 };
1173 conn.query_row(
1174 "SELECT mine FROM events WHERE id = ?1",
1175 [event_id],
1176 |row| row.get::<_, i64>(0),
1177 )
1178 .map(|mine| mine == 1)
1179 .unwrap_or(false)
1180}
1181
1182fn extract_tag_from_json(tags_json: &str, key: &str) -> Option<String> {
1188 if tags_json.len() <= 2 { return None; }
1189 let pattern = format!("[\"{}\"", key);
1190 if !tags_json.contains(&pattern) { return None; }
1191 let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1192 tags.into_iter()
1193 .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
1194 .and_then(|tag| tag.into_iter().nth(1))
1195}
1196
1197
1198fn normalize_reaction_author(author: String) -> String {
1202 if author.len() == 64 && author.bytes().all(|b| b.is_ascii_hexdigit()) {
1203 if let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(&author) {
1204 use nostr_sdk::prelude::ToBech32;
1205 let Ok(npub) = pk.to_bech32();
1206 return npub;
1207 }
1208 }
1209 author
1210}
1211fn extract_reaction_emoji_url(tags: &[Vec<String>], content: &str) -> Option<String> {
1217 if !content.starts_with(':') || !content.ends_with(':') || content.len() < 3 {
1218 return None;
1219 }
1220 let sc = &content[1..content.len() - 1];
1221 tags.iter().find_map(|t| {
1222 if t.len() >= 3 && t[0] == "emoji" && t[1] == sc {
1223 Some(t[2].clone())
1224 } else {
1225 None
1226 }
1227 })
1228}
1229
1230fn extract_reply_tag_from_json(tags_json: &str) -> Option<String> {
1232 if tags_json.len() <= 2 { return None; }
1233 if !tags_json.contains("[\"e\"") { return None; }
1234 let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1235 tags.into_iter()
1236 .find(|tag| {
1237 tag.first().map(|s| s.as_str()) == Some("e")
1238 && tag.get(3).map(|s| s.as_str()) == Some("reply")
1239 })
1240 .and_then(|tag| tag.into_iter().nth(1))
1241}
1242
1243pub async fn get_message_views(
1248 chat_id: i64,
1249 limit: usize,
1250 offset: usize,
1251) -> Result<Vec<Message>, String> {
1252 let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1254 let message_events = get_events(chat_id, Some(&message_kinds), limit, offset).await?;
1255
1256 compose_message_views(message_events).await
1257}
1258
1259async fn compose_message_views(message_events: Vec<StoredEvent>) -> Result<Vec<Message>, String> {
1264 use std::collections::HashMap;
1265
1266 if message_events.is_empty() {
1267 return Ok(Vec::new());
1268 }
1269
1270 let message_ids: Vec<String> = message_events.iter().map(|e| e.id.clone()).collect();
1272 let related_events = get_related_events(&message_ids).await?;
1273
1274 let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1275 let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1276
1277 crate::crypto::open_batch(|at_rest| {
1278 for event in related_events {
1279 if let Some(ref_id) = &event.reference_id {
1280 match event.kind {
1281 k if k == event_kind::REACTION => {
1282 let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1283 let reaction = Reaction {
1284 id: event.id.clone(),
1285 reference_id: ref_id.clone(),
1286 author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1287 emoji: event.content.clone(),
1288 emoji_url,
1289 };
1290 let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1293 if !slot.iter().any(|r| r.same_slot(&reaction)) {
1294 slot.push(reaction);
1295 }
1296 }
1297 k if k == event_kind::MESSAGE_EDIT => {
1298 let decrypted = at_rest.open(event.content.clone())
1299 .unwrap_or_else(|_| event.content.clone());
1300 let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1301 edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1302 }
1303 _ => {}
1304 }
1305 }
1306 }
1307 });
1308
1309 for edits in edits_by_msg.values_mut() {
1310 edits.sort_by_key(|(ts, _, _)| *ts);
1311 }
1312
1313 let attach_ids: Vec<String> = message_events.iter()
1316 .filter(|e| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1317 .map(|e| e.id.clone())
1318 .collect();
1319 let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1320 .unwrap_or_default();
1321 for event in &message_events {
1322 if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1323 continue;
1324 }
1325 if attachments_by_msg.contains_key(&event.id) {
1326 continue;
1327 }
1328 if let Some(json) = event.get_tag("attachments") {
1329 if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(json) {
1330 if !atts.is_empty() {
1331 attachments_by_msg.insert(event.id.clone(), atts);
1332 }
1333 }
1334 }
1335 }
1336
1337 let mut messages = Vec::with_capacity(message_events.len());
1339 for event in message_events {
1340 let replied_to = event.get_reply_reference().unwrap_or("").to_string();
1341 let at = event.timestamp_ms();
1342 let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1343 let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1344
1345 let original_content = if event.kind == event_kind::FILE_ATTACHMENT {
1346 String::new()
1347 } else {
1348 event.content.clone()
1349 };
1350
1351 let original_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1354 let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1355 let mut history = Vec::with_capacity(edits.len() + 1);
1356 history.push(crate::types::EditEntry { content: original_content.clone(), edited_at: at });
1357 for (ts, c, _) in &edits {
1358 history.push(crate::types::EditEntry { content: c.clone(), edited_at: *ts });
1359 }
1360 let (latest, latest_emoji) = edits.last()
1361 .map(|(_, c, e)| (c.clone(), e.clone()))
1362 .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1363 (latest, true, Some(history), latest_emoji)
1364 } else {
1365 (original_content, false, None, original_emoji)
1366 };
1367
1368 let preview_metadata = event.preview_metadata
1369 .and_then(|json| serde_json::from_str(&json).ok());
1370
1371 let addressed_bots = extract_bot_tags(&event.tags);
1372 let expiration = extract_expiration_tag(&event.tags);
1373 messages.push(Message {
1374 expiration,
1375 id: event.id, content, replied_to,
1376 replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1377 replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1378 preview_metadata, attachments, reactions, at,
1379 pending: event.pending, failed: event.failed, mine: event.mine,
1380 npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1381 edited, edit_history,
1382 emoji_tags,
1383 addressed_bots,
1384 });
1385 }
1386
1387 crate::self_destruct::strip_expired(&mut messages);
1391
1392 let reply_ids: Vec<String> = messages.iter()
1394 .filter(|m| !m.replied_to.is_empty())
1395 .map(|m| m.replied_to.clone())
1396 .collect();
1397
1398 if !reply_ids.is_empty() {
1399 let contexts = get_reply_contexts(&reply_ids).await?;
1400 for msg in &mut messages {
1401 if let Some(ctx) = contexts.get(&msg.replied_to) {
1402 msg.replied_to_content = Some(ctx.content.clone());
1403 msg.replied_to_npub = ctx.npub.clone();
1404 msg.replied_to_has_attachment = Some(ctx.has_attachment);
1405 msg.replied_to_attachment_extension = ctx.extension.clone();
1406 msg.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1407 }
1408 }
1409 }
1410
1411 Ok(messages)
1412}
1413
1414pub async fn get_message_by_id(message_id: &str) -> Result<Option<(String, Message)>, String> {
1429 let kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT]
1430 .map(|k| k.to_string())
1431 .join(",");
1432 let found = {
1433 let conn = super::get_db_connection_guard_static()?;
1434 let sql = format!(
1436 "SELECT e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1437 e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, \
1438 e.preview_metadata, c.chat_identifier \
1439 FROM events e JOIN chats c ON c.id = e.chat_id \
1440 WHERE e.id = ?1 AND e.kind IN ({kinds})"
1441 );
1442 let mut stmt = conn.prepare(&sql).map_err(|e| format!("prepare get_message_by_id: {e}"))?;
1443 stmt.query_row(rusqlite::params![message_id], |r| Ok((parse_event_row(r)?, r.get::<_, String>(15)?)))
1444 .optional()
1445 .map_err(|e| format!("query get_message_by_id: {e}"))?
1446 };
1447 let Some((event, chat_identifier)) = found else { return Ok(None) };
1448 Ok(compose_message_views(vec![event]).await?.pop().map(|m| (chat_identifier, m)))
1449}
1450
1451pub async fn get_messages_around(
1452 chat_id: i64,
1453 anchor_id: &str,
1454 before: usize,
1455 after: usize,
1456) -> Result<Vec<Message>, String> {
1457 let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1458
1459 let message_events: Vec<StoredEvent> = {
1460 let conn = super::get_db_connection_guard_static()?;
1461 let community = community_of_chat(&conn, chat_id);
1462
1463 let (anchor_at, anchor_rt, anchor_rowid): (i64, i64, i64) = conn.query_row(
1469 "SELECT created_at, received_at, rowid FROM events WHERE id = ?1",
1470 rusqlite::params![anchor_id],
1471 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1472 ).map_err(|e| format!("Anchor message not found: {}", e))?;
1473
1474 let kind_placeholders: String = (0..message_kinds.len())
1476 .map(|i| format!("?{}", i + 2))
1477 .collect::<Vec<_>>()
1478 .join(",");
1479 let cols = "id, kind, chat_id, user_id, content, tags, reference_id, \
1480 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata";
1481
1482 let older_sql = format!(
1484 "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1485 AND (created_at < ?5 OR (created_at = ?5 AND (received_at < ?6 \
1486 OR (received_at = ?6 AND rowid <= ?7)))) \
1487 {} ORDER BY created_at DESC, received_at DESC, rowid DESC LIMIT ?8",
1488 cols, kind_placeholders, ban_filter_sql(9)
1489 );
1490 let mut older_stmt = conn.prepare(&older_sql)
1491 .map_err(|e| format!("Failed to prepare older window query: {}", e))?;
1492 let older_rows = older_stmt.query_map(
1493 rusqlite::params![
1494 chat_id,
1495 message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1496 anchor_at, anchor_rt, anchor_rowid, before as i64, community
1497 ],
1498 parse_event_row,
1499 ).map_err(|e| format!("Failed to query older window: {}", e))?;
1500 let mut older: Vec<StoredEvent> = older_rows.filter_map(|r| r.ok()).collect();
1501 older.reverse(); let newer_sql = format!(
1505 "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1506 AND (created_at > ?5 OR (created_at = ?5 AND (received_at > ?6 \
1507 OR (received_at = ?6 AND rowid > ?7)))) \
1508 {} ORDER BY created_at ASC, received_at ASC, rowid ASC LIMIT ?8",
1509 cols, kind_placeholders, ban_filter_sql(9)
1510 );
1511 let mut newer_stmt = conn.prepare(&newer_sql)
1512 .map_err(|e| format!("Failed to prepare newer window query: {}", e))?;
1513 let newer_rows = newer_stmt.query_map(
1514 rusqlite::params![
1515 chat_id,
1516 message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1517 anchor_at, anchor_rt, anchor_rowid, after as i64, community
1518 ],
1519 parse_event_row,
1520 ).map_err(|e| format!("Failed to query newer window: {}", e))?;
1521 let newer: Vec<StoredEvent> = newer_rows.filter_map(|r| r.ok()).collect();
1522
1523 older.into_iter().chain(newer).collect()
1524 };
1525
1526 let decrypted: Vec<StoredEvent> = crate::crypto::open_batch(|at_rest| message_events.into_iter().map(|mut event| {
1528 if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
1529 event.content = at_rest.open(event.content)
1530 .unwrap_or_else(|_| "[Decryption failed]".to_string());
1531 }
1532 event
1533 }).collect());
1534
1535 compose_message_views(decrypted).await
1536}
1537
1538pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
1541 use std::collections::HashMap;
1542
1543 let message_events: Vec<(String, StoredEvent, String)> = {
1545 let conn = super::get_db_connection_guard_static()?;
1546 let mut stmt = conn.prepare(
1547 "SELECT c.chat_identifier, \
1548 e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1549 e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, e.preview_metadata \
1550 FROM chats c JOIN events e ON e.rowid = ( \
1551 SELECT e2.rowid FROM events e2 WHERE e2.chat_id = c.id \
1552 AND e2.kind IN (?1, ?2, ?3) \
1553 ORDER BY e2.created_at DESC, e2.received_at DESC LIMIT 1) \
1554 WHERE c.chat_type != 1"
1555 ).map_err(|e| format!("Failed to prepare: {}", e))?;
1556
1557 let rows = stmt.query_map(
1558 rusqlite::params![
1559 event_kind::CHAT_MESSAGE as i32,
1560 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1561 event_kind::FILE_ATTACHMENT as i32
1562 ],
1563 |row| {
1564 let chat_id: String = row.get(0)?;
1565 let tags_json: String = row.get(6)?;
1566 let event = StoredEvent {
1567 id: row.get(1)?, kind: row.get::<_, i32>(2)? as u16,
1568 chat_id: row.get(3)?, user_id: row.get(4)?, content: row.get(5)?,
1569 tags: Vec::new(), reference_id: row.get(7)?,
1571 created_at: row.get::<_, i64>(8)? as u64, received_at: row.get::<_, i64>(9)? as u64,
1572 mine: row.get::<_, i32>(10)? != 0, pending: row.get::<_, i32>(11)? != 0,
1573 failed: row.get::<_, i32>(12)? != 0, wrapper_event_id: row.get(13)?,
1574 npub: row.get(14)?, preview_metadata: row.get(15)?,
1575 };
1576 Ok((chat_id, event, tags_json))
1577 }
1578 ).map_err(|e| format!("Failed to query: {}", e))?;
1579 rows.filter_map(|r| r.ok()).collect()
1580 };
1581
1582 if message_events.is_empty() {
1583 return Ok(HashMap::new());
1584 }
1585
1586 let message_ids: Vec<String> = message_events.iter().map(|(_, e, _)| e.id.clone()).collect();
1588 let related_events = get_related_events(&message_ids).await?;
1589
1590 let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1591 let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1592
1593 crate::crypto::open_batch(|at_rest| {
1594 for event in related_events {
1595 if let Some(ref_id) = &event.reference_id {
1596 match event.kind {
1597 k if k == event_kind::REACTION => {
1598 let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1599 let reaction = Reaction {
1600 id: event.id.clone(), reference_id: ref_id.clone(),
1601 author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1602 emoji: event.content.clone(),
1603 emoji_url,
1604 };
1605 let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1608 if !slot.iter().any(|r| r.same_slot(&reaction)) {
1609 slot.push(reaction);
1610 }
1611 }
1612 k if k == event_kind::MESSAGE_EDIT => {
1613 let decrypted = at_rest.open(event.content.clone())
1614 .unwrap_or_else(|_| event.content.clone());
1615 let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1616 edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1617 }
1618 _ => {}
1619 }
1620 }
1621 }
1622 });
1623 for edits in edits_by_msg.values_mut() {
1624 edits.sort_by_key(|(ts, _, _)| *ts);
1625 }
1626
1627 let attach_ids: Vec<String> = message_events.iter()
1629 .filter(|(_, e, _)| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1630 .map(|(_, e, _)| e.id.clone())
1631 .collect();
1632 let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1633 .unwrap_or_default();
1634 for (_, event, tags_json) in &message_events {
1635 if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1636 continue;
1637 }
1638 if attachments_by_msg.contains_key(&event.id) {
1639 continue;
1640 }
1641 if let Some(val) = extract_tag_from_json(tags_json, "attachments") {
1642 if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&val) {
1643 if !atts.is_empty() {
1644 attachments_by_msg.insert(event.id.clone(), atts);
1645 }
1646 }
1647 }
1648 }
1649
1650 let mut result: HashMap<String, Vec<Message>> = HashMap::new();
1652
1653 crate::crypto::open_batch(|at_rest| {
1654 for (chat_identifier, event, tags_json) in message_events {
1655 let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1656 let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1657 let replied_to = extract_reply_tag_from_json(&tags_json).unwrap_or_default();
1658
1659 let original_content = if event.kind == event_kind::CHAT_MESSAGE
1661 || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
1662 {
1663 at_rest.open(event.content.clone())
1664 .unwrap_or_else(|_| "[Decryption failed]".to_string())
1665 } else {
1666 String::new()
1667 };
1668
1669 let stored_tags = serde_json::from_str::<Vec<Vec<String>>>(&tags_json).unwrap_or_default();
1670 let original_emoji = crate::types::EmojiTag::extract_from_stored(&stored_tags);
1671 let addressed_bots = extract_bot_tags(&stored_tags);
1672 let expiration = extract_expiration_tag(&stored_tags);
1673 let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1675 let (latest, latest_emoji) = edits.last()
1676 .map(|(_, c, e)| (c.clone(), e.clone()))
1677 .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1678 let history: Vec<crate::types::EditEntry> = std::iter::once(crate::types::EditEntry {
1679 content: original_content, edited_at: event.created_at * 1000,
1680 }).chain(edits.into_iter().map(|(ts, c, _)| crate::types::EditEntry { content: c, edited_at: ts }))
1681 .collect();
1682 (latest, true, Some(history), latest_emoji)
1683 } else {
1684 (original_content, false, None, original_emoji)
1685 };
1686
1687 let preview_metadata = event.preview_metadata
1688 .and_then(|json| serde_json::from_str(&json).ok());
1689
1690 result.entry(chat_identifier).or_default().push(Message {
1691 expiration,
1692 id: event.id, content, replied_to,
1693 replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1694 replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1695 preview_metadata, attachments, reactions, at: event.created_at * 1000,
1696 pending: event.pending, failed: event.failed, mine: event.mine,
1697 npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1698 edited, edit_history,
1699 emoji_tags,
1700 addressed_bots,
1701 });
1702 }
1703 });
1704
1705 let reply_ids: Vec<String> = result.values()
1709 .flatten()
1710 .filter(|m| !m.replied_to.is_empty())
1711 .map(|m| m.replied_to.clone())
1712 .collect();
1713
1714 if !reply_ids.is_empty() {
1715 let contexts = get_reply_contexts(&reply_ids).await?;
1716 for msg in result.values_mut().flatten() {
1717 if let Some(ctx) = contexts.get(&msg.replied_to) {
1718 msg.replied_to_content = Some(ctx.content.clone());
1719 msg.replied_to_npub = ctx.npub.clone();
1720 msg.replied_to_has_attachment = Some(ctx.has_attachment);
1721 msg.replied_to_attachment_extension = ctx.extension.clone();
1722 msg.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1723 }
1724 }
1725 }
1726
1727 for msgs in result.values_mut() {
1731 crate::self_destruct::strip_expired(msgs);
1732 }
1733
1734 Ok(result)
1735}
1736
1737pub async fn unread_counts() -> Result<std::collections::HashMap<String, u32>, String> {
1748 let conn = super::get_db_connection_guard_static()?;
1749 let mut stmt = conn
1752 .prepare_cached(
1753 "WITH anchors AS MATERIALIZED ( \
1754 SELECT c.id AS chat_id, c.chat_identifier AS chat_identifier, MAX( \
1755 COALESCE((SELECT MAX(created_at) FROM events WHERE chat_id = c.id AND mine = 1 AND kind = ?1), 0), \
1756 COALESCE((SELECT MAX(created_at) FROM events WHERE chat_id = c.id AND mine = 1 AND kind = ?2), 0), \
1757 COALESCE((SELECT MAX(created_at) FROM events WHERE chat_id = c.id AND mine = 1 AND kind = ?3), 0), \
1758 COALESCE((SELECT created_at FROM events WHERE id = c.last_read AND chat_id = c.id), 0) \
1759 ) AS anchor_ts \
1760 FROM chats c \
1761 ), \
1762 counts AS MATERIALIZED ( \
1763 SELECT a.chat_identifier AS chat_identifier, ( \
1764 SELECT COUNT(*) FROM events e \
1765 WHERE e.chat_id = a.chat_id AND e.mine = 0 AND e.kind IN (?1, ?2, ?3) \
1766 AND e.created_at > a.anchor_ts \
1767 AND (e.npub IS NULL OR e.npub NOT IN ( \
1768 SELECT chat_identifier FROM chats WHERE muted = 1 \
1769 UNION \
1770 SELECT npub FROM profiles WHERE is_blocked = 1)) \
1771 AND NOT EXISTS ( \
1772 SELECT 1 FROM community_channels cc \
1773 JOIN community_bans b ON b.community_id = cc.community_id AND b.npub = e.npub \
1774 WHERE cc.channel_id = a.chat_identifier) \
1775 ) AS unread \
1776 FROM anchors a \
1777 ) \
1778 SELECT chat_identifier, unread FROM counts WHERE unread > 0",
1779 )
1780 .map_err(|e| format!("prepare unread_counts: {e}"))?;
1781 let rows = stmt
1782 .query_map(
1783 rusqlite::params![
1784 event_kind::CHAT_MESSAGE as i32,
1785 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1786 event_kind::FILE_ATTACHMENT as i32
1787 ],
1788 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)),
1789 )
1790 .map_err(|e| format!("query unread_counts: {e}"))?;
1791 let mut out = std::collections::HashMap::new();
1792 for r in rows.flatten() {
1793 out.insert(r.0, r.1);
1794 }
1795 Ok(out)
1796}
1797
1798pub async fn unread_count_for_chat(chat_identifier: &str) -> Result<u32, String> {
1801 let conn = super::get_db_connection_guard_static()?;
1802 let count: i64 = conn
1803 .prepare_cached(
1804 "SELECT COUNT(*) FROM events e JOIN chats c ON e.chat_id = c.id \
1805 WHERE c.chat_identifier = ?4 AND e.kind IN (?1, ?2, ?3) AND e.mine = 0 \
1806 AND (e.npub IS NULL OR e.npub NOT IN ( \
1807 SELECT chat_identifier FROM chats WHERE muted = 1 \
1808 UNION \
1809 SELECT npub FROM profiles WHERE is_blocked = 1)) \
1810 AND NOT EXISTS ( \
1811 SELECT 1 FROM community_channels cc \
1812 JOIN community_bans b ON b.community_id = cc.community_id AND b.npub = e.npub \
1813 WHERE cc.channel_id = c.chat_identifier) \
1814 AND e.created_at > COALESCE(( \
1815 SELECT MAX(e2.created_at) FROM events e2 \
1816 WHERE e2.chat_id = c.id \
1817 AND ((e2.mine = 1 AND e2.kind IN (?1, ?2, ?3)) OR e2.id = c.last_read)), 0)",
1818 )
1819 .and_then(|mut stmt| {
1820 stmt.query_row(
1821 rusqlite::params![
1822 event_kind::CHAT_MESSAGE as i32,
1823 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1824 event_kind::FILE_ATTACHMENT as i32,
1825 chat_identifier
1826 ],
1827 |row| row.get(0),
1828 )
1829 })
1830 .map_err(|e| format!("query unread_count_for_chat: {e}"))?;
1831 Ok(count as u32)
1832}
1833
1834#[derive(Debug, PartialEq)]
1838pub enum UnreadMark {
1839 NoOp,
1841 Clear,
1843 Anchor(String),
1845}
1846
1847pub async fn compute_unread_anchor(chat_identifier: &str) -> Result<UnreadMark, String> {
1851 let conn = super::get_db_connection_guard_static()?;
1852 let (k0, k1, k2) = (
1853 event_kind::CHAT_MESSAGE as i32,
1854 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1855 event_kind::FILE_ATTACHMENT as i32,
1856 );
1857 let (target_ts, newest_ts): (Option<i64>, Option<i64>) = conn
1859 .query_row(
1860 "SELECT MAX(CASE WHEN e.mine = 0 THEN e.created_at END), MAX(e.created_at) \
1861 FROM events e JOIN chats c ON e.chat_id = c.id \
1862 WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4)",
1863 rusqlite::params![chat_identifier, k0, k1, k2],
1864 |row| Ok((row.get(0)?, row.get(1)?)),
1865 )
1866 .map_err(|e| format!("unread anchor target: {e}"))?;
1867
1868 let target_ts = match target_ts {
1869 Some(t) => t,
1870 None => return Ok(UnreadMark::NoOp), };
1872 if newest_ts.is_some_and(|n| n > target_ts) {
1873 return Ok(UnreadMark::NoOp); }
1875
1876 let anchor_id: Option<String> = conn
1877 .query_row(
1878 "SELECT e.id FROM events e JOIN chats c ON e.chat_id = c.id \
1879 WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4) AND e.created_at < ?5 \
1880 ORDER BY e.created_at DESC LIMIT 1",
1881 rusqlite::params![chat_identifier, k0, k1, k2, target_ts],
1882 |row| row.get(0),
1883 )
1884 .optional()
1885 .map_err(|e| format!("unread anchor prev: {e}"))?;
1886
1887 Ok(match anchor_id {
1888 Some(id) => UnreadMark::Anchor(id),
1889 None => UnreadMark::Clear,
1890 })
1891}
1892
1893pub async fn flush_message_batch(
1900 chat_id: &str,
1901 pending: &mut Vec<&Message>,
1902 session: &std::sync::Arc<crate::db::Session>,
1903) {
1904 crate::db::scoped(async move {
1905 if pending.is_empty() {
1906 return;
1907 }
1908 if !session.is_live() {
1909 pending.clear();
1910 return;
1911 }
1912 if let Err(e) = save_messages_batch(chat_id, pending).await {
1913 crate::log_warn!("[DB] batch flush failed for {}: {}", chat_id, e);
1914 }
1915 pending.clear();
1916 })
1917 .await
1918}
1919
1920pub async fn save_chat_messages(chat_id: &str, messages: &[Message]) -> Result<(), String> {
1922 if messages.is_empty() {
1923 return Ok(());
1924 }
1925 let refs: Vec<&Message> = messages.iter().collect();
1926 save_messages_batch(chat_id, &refs).await.map(|_| ())
1927}
1928
1929#[cfg(test)]
1930mod tests {
1931 use super::*;
1932 use crate::stored_event::SystemEventType;
1933
1934 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(71000);
1935
1936 fn make_test_npub(n: u32) -> String {
1937 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1938 let mut payload = vec![b'q'; 58];
1939 let mut x = n as u64;
1940 let mut i = 58;
1941 while x > 0 && i > 0 {
1942 i -= 1;
1943 payload[i] = BECH32[(x as usize) % 32];
1944 x /= 32;
1945 }
1946 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1947 }
1948
1949 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1950 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1951 crate::db::close_database();
1952 crate::db::clear_id_caches();
1955 let tmp = tempfile::tempdir().unwrap();
1956 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1957 let account = make_test_npub(n);
1958 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1959 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1960 crate::db::set_current_account(account.clone()).unwrap();
1961 crate::db::init_database(&account).unwrap();
1962 (tmp, guard)
1963 }
1964
1965 fn now_secs() -> u64 {
1966 web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).unwrap().as_secs()
1967 }
1968
1969 #[tokio::test]
1976 async fn batch_resolver_fills_quotes_for_a_synced_page() {
1977 let (_tmp, _guard) = init_test_db();
1978 let chat = "channel_reply_batch";
1979 let author = make_test_npub(90_001);
1980
1981 let mut parent = Message::default();
1983 parent.id = "parent_evt".to_string();
1984 parent.content = "the original message".to_string();
1985 parent.npub = Some(author.clone());
1986 parent.at = now_secs() - 5_000;
1987 save_message(chat, &parent).await.unwrap();
1988
1989 let mut reply = Message::default();
1991 reply.id = "reply_evt".to_string();
1992 reply.content = "replying now".to_string();
1993 reply.replied_to = "parent_evt".to_string();
1994 reply.at = now_secs();
1995 let mut plain = Message::default();
1996 plain.id = "plain_evt".to_string();
1997 plain.content = "unrelated".to_string();
1998 plain.at = now_secs();
1999 let mut orphan = Message::default();
2001 orphan.id = "orphan_evt".to_string();
2002 orphan.replied_to = "never_seen_evt".to_string();
2003 orphan.at = now_secs();
2004
2005 populate_reply_contexts(vec![&mut reply, &mut plain, &mut orphan]).await.unwrap();
2006
2007 assert_eq!(
2008 reply.replied_to_content.as_deref(),
2009 Some("the original message"),
2010 "the synced reply carries its parent's content"
2011 );
2012 assert_eq!(reply.replied_to_npub.as_deref(), Some(author.as_str()), "and its parent's author");
2013 assert!(plain.replied_to_content.is_none(), "a non-reply is untouched");
2014 assert!(orphan.replied_to_content.is_none(), "an unresolvable parent leaves the quote empty");
2015 }
2016
2017 #[tokio::test]
2020 async fn a_message_resolves_by_id_with_the_chat_it_was_said_in() {
2021 let (_tmp, _guard) = init_test_db();
2022 let chat = "channel_resolve_by_id";
2023 let author = make_test_npub(91_001);
2024
2025 let mut msg = Message::default();
2026 msg.id = "cited_evt".to_string();
2027 msg.content = "the line a rule matched".to_string();
2028 msg.npub = Some(author.clone());
2029 msg.at = now_secs();
2030 save_message(chat, &msg).await.unwrap();
2031
2032 let (found_chat, found) = get_message_by_id("cited_evt").await.unwrap().expect("stored message resolves");
2033 assert_eq!(found_chat, chat, "the citation gets the room back, not just the message");
2034 assert_eq!(found.content, "the line a rule matched", "and the text, so a mod log can quote it");
2035 assert_eq!(found.npub.as_deref(), Some(author.as_str()));
2036 }
2037
2038 #[tokio::test]
2042 async fn a_missing_message_resolves_to_none_rather_than_erroring() {
2043 let (_tmp, _guard) = init_test_db();
2044 assert!(get_message_by_id("never_stored_evt").await.unwrap().is_none());
2045 }
2046
2047 #[tokio::test]
2050 async fn an_attachment_hash_finds_every_message_that_carried_it() {
2051 let (_tmp, _guard) = init_test_db();
2052 let chat = "channel_same_blob";
2053 let hash = "cc".repeat(32);
2054
2055 let now_ms = now_secs() * 1000;
2058 for (id, at) in [("blob_older", now_ms - 500_000), ("blob_newer", now_ms)] {
2059 let mut msg = Message::default();
2060 msg.id = id.to_string();
2061 msg.npub = Some(make_test_npub(92_001));
2062 msg.at = at;
2063 msg.attachments = vec![crate::types::Attachment {
2064 id: hash.clone(),
2065 extension: "png".into(),
2066 name: "poster.png".into(),
2067 ..Default::default()
2068 }];
2069 save_message(chat, &msg).await.unwrap();
2070 }
2071
2072 let carriers = crate::db::attachments::events_with_attachment_hash(&hash).unwrap();
2073 assert_eq!(carriers, vec!["blob_newer", "blob_older"], "every carrier, newest first");
2074
2075 for id in ["tie_a", "tie_b"] {
2078 let mut msg = Message::default();
2079 msg.id = id.to_string();
2080 msg.npub = Some(make_test_npub(92_002));
2081 msg.at = now_ms;
2082 msg.attachments = vec![crate::types::Attachment { id: hash.clone(), ..Default::default() }];
2083 save_message(chat, &msg).await.unwrap();
2084 }
2085 let a = crate::db::attachments::events_with_attachment_hash(&hash).unwrap();
2086 let b = crate::db::attachments::events_with_attachment_hash(&hash).unwrap();
2087 assert_eq!(a, b, "a same-instant cluster orders the same way every read");
2088 assert_eq!(a.len(), 4, "and every carrier is still listed");
2089 assert!(
2090 crate::db::attachments::events_with_attachment_hash(&"ab".repeat(32)).unwrap().is_empty(),
2091 "a hash nobody posted finds nothing"
2092 );
2093 }
2094
2095 #[tokio::test]
2098 async fn batch_resolver_is_a_noop_without_replies() {
2099 let (_tmp, _guard) = init_test_db();
2100 populate_reply_contexts(vec![]).await.unwrap();
2101 let mut plain = Message::default();
2102 plain.id = "solo".to_string();
2103 populate_reply_contexts(vec![&mut plain]).await.unwrap();
2104 assert!(plain.replied_to_content.is_none());
2105 }
2106
2107 #[tokio::test]
2111 async fn system_event_stamps_authenticated_time_clamped_to_now() {
2112 let (_tmp, _guard) = init_test_db();
2113 let chat = "channel_ch2_timestamp";
2114 let before = now_secs();
2115 let past = before - 100_000;
2116
2117 save_system_event_at("ev_past", chat, SystemEventType::MemberJoined, "npubX", None, past, None, None).await.unwrap();
2118 save_system_event_at("ev_future", chat, SystemEventType::MemberJoined, "npubX", None, before + 100_000, None, None).await.unwrap();
2119 let after = now_secs();
2120
2121 let evs = get_system_events_for_chat(chat).unwrap();
2122 let past_ev = evs.iter().find(|e| e.id == "ev_past").expect("past event saved");
2123 assert_eq!(past_ev.created_at, past, "historical join keeps its real (authenticated) timestamp");
2124
2125 let fut_ev = evs.iter().find(|e| e.id == "ev_future").expect("future event saved");
2126 assert!(fut_ev.created_at >= before && fut_ev.created_at <= after,
2127 "future-dated event clamped to local now ({} not in {}..={})", fut_ev.created_at, before, after);
2128 }
2129
2130 #[tokio::test]
2133 async fn event_delete_context_resolves_from_db() {
2134 let (_tmp, _guard) = init_test_db();
2135 let chat = "npub1contactdc";
2136
2137 let mine_msg = Message { id: "dc_mine".into(), content: "x".into(), at: 1_000, mine: true, ..Default::default() };
2138 let theirs = Message {
2139 id: "dc_theirs".into(), content: "y".into(), at: 2_000, mine: false,
2140 npub: Some("npub1sender".to_string()),
2141 ..Default::default()
2142 };
2143 save_message(chat, &mine_msg).await.unwrap();
2144 save_message(chat, &theirs).await.unwrap();
2145
2146 let (chat_id, mine, _author) = event_delete_context("dc_mine").unwrap().expect("own row resolves");
2147 assert_eq!(chat_id, chat);
2148 assert!(mine);
2149
2150 let (chat_id, mine, author) = event_delete_context("dc_theirs").unwrap().expect("contact row resolves");
2151 assert_eq!(chat_id, chat);
2152 assert!(!mine);
2153 assert_eq!(author.as_deref(), Some("npub1sender"));
2154
2155 assert!(event_delete_context("dc_absent").unwrap().is_none(), "unknown id is None, not an error");
2156 }
2157
2158 #[tokio::test]
2161 async fn unread_counts_match_walk_back_semantics() {
2162 let (_tmp, _guard) = init_test_db();
2163 let chat = "npub1contactdm";
2164 let mk = |id: &str, secs: u64, mine: bool| Message {
2166 id: id.into(), content: "x".into(), at: secs * 1000, mine,
2167 npub: (!mine).then(|| "npub1sender".to_string()),
2168 ..Default::default()
2169 };
2170 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2171
2172 for i in 0..6u64 {
2174 save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap();
2175 }
2176 assert_eq!(unread().await, 6, "never-read backlog counts all 6");
2177
2178 save_message(chat, &mk("m6", 2000, false)).await.unwrap();
2180 save_message(chat, &mk("m7", 2001, false)).await.unwrap();
2181 assert_eq!(unread().await, 8, "6 backlog + 2 new = 8");
2182
2183 save_message(chat, &mk("mine", 2002, true)).await.unwrap();
2185 assert_eq!(unread().await, 0, "own message = read up to here");
2186
2187 save_message(chat, &mk("m8", 2003, false)).await.unwrap();
2189 assert_eq!(unread().await, 1, "one new after our send");
2190
2191 {
2193 let conn = crate::db::get_write_connection_guard_static().unwrap();
2194 conn.execute(
2195 "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2196 rusqlite::params!["m8", chat],
2197 ).unwrap();
2198 }
2199 assert_eq!(unread().await, 0, "last_read=m8 clears all");
2200 save_message(chat, &mk("m9", 2004, false)).await.unwrap();
2201 assert_eq!(unread().await, 1, "one arrival after last_read");
2202 }
2203
2204 #[tokio::test]
2207 async fn unread_counts_exclude_muted_and_blocked_senders() {
2208 let (_tmp, _guard) = init_test_db();
2209 let chat = "channel_mute_exclusion";
2210 let mk = |id: &str, secs: u64, npub: &str| Message {
2211 id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2212 npub: Some(npub.to_string()),
2213 ..Default::default()
2214 };
2215 save_message(chat, &mk("n1", 1000, "npub1noisy")).await.unwrap();
2216 save_message(chat, &mk("q1", 1001, "npub1quiet")).await.unwrap();
2217
2218 let map = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2219 let one = || async { unread_count_for_chat(chat).await.unwrap() };
2220 assert_eq!(map().await, 2, "both senders count while neither is muted");
2221 assert_eq!(one().await, 2);
2222
2223 {
2225 let conn = crate::db::get_write_connection_guard_static().unwrap();
2226 conn.execute(
2227 "INSERT INTO chats (chat_identifier, chat_type, participants, created_at, muted) \
2228 VALUES ('npub1noisy', 0, '', 1000, 1)",
2229 [],
2230 ).unwrap();
2231 }
2232 assert_eq!(map().await, 1, "muted sender's message stops counting");
2233 assert_eq!(one().await, 1);
2234
2235 {
2237 let conn = crate::db::get_write_connection_guard_static().unwrap();
2238 conn.execute(
2239 "INSERT INTO profiles (npub, is_blocked) VALUES ('npub1quiet', 1) \
2240 ON CONFLICT(npub) DO UPDATE SET is_blocked = 1",
2241 [],
2242 ).unwrap();
2243 }
2244 assert_eq!(map().await, 0, "blocked sender's message stops counting too");
2245 assert_eq!(one().await, 0);
2246
2247 {
2249 let conn = crate::db::get_write_connection_guard_static().unwrap();
2250 conn.execute("UPDATE chats SET muted = 0 WHERE chat_identifier = 'npub1noisy'", []).unwrap();
2251 }
2252 assert_eq!(map().await, 1, "unmuting restores the sender's count");
2253 assert_eq!(one().await, 1);
2254 }
2255
2256 #[tokio::test]
2259 async fn unread_count_for_chat_matches_the_map() {
2260 let (_tmp, _guard) = init_test_db();
2261 let chat = "npub1reconcile";
2262 let mk = |id: &str, secs: u64, mine: bool| Message {
2263 id: id.into(), content: "x".into(), at: secs * 1000, mine,
2264 npub: (!mine).then(|| "npub1sender".to_string()),
2265 ..Default::default()
2266 };
2267 let agree = || async {
2268 let map = unread_counts().await.unwrap().get(chat).copied().unwrap_or(0);
2269 let one = unread_count_for_chat(chat).await.unwrap();
2270 assert_eq!(map, one, "single-chat query diverged from the map");
2271 one
2272 };
2273
2274 for i in 0..4u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap(); }
2275 assert_eq!(agree().await, 4);
2276 save_message(chat, &mk("mine", 1010, true)).await.unwrap();
2277 assert_eq!(agree().await, 0);
2278 save_message(chat, &mk("after", 1011, false)).await.unwrap();
2279 assert_eq!(agree().await, 1);
2280 {
2281 let conn = crate::db::get_write_connection_guard_static().unwrap();
2282 conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2283 rusqlite::params!["after", chat]).unwrap();
2284 }
2285 assert_eq!(agree().await, 0);
2286 assert_eq!(unread_count_for_chat("npub1nonexistent").await.unwrap(), 0);
2288 }
2289
2290 #[tokio::test]
2291 async fn attachments_table_round_trip_dedup_and_cascade() {
2292 let (_tmp, _guard) = init_test_db();
2293 let att = |id: &str, name: &str| Attachment {
2295 id: id.into(), url: format!("https://blossom/{id}"), name: name.into(),
2296 extension: "png".into(), size: 42, downloaded: false, ..Default::default()
2297 };
2298 let msg = |mid: &str, secs: u64, atts: Vec<Attachment>| Message {
2299 id: mid.into(), content: String::new(), at: secs * 1000, mine: false,
2300 npub: Some("npub1sender".into()), attachments: atts, ..Default::default()
2301 };
2302
2303 save_message("npub1att", &msg("m1", 1000, vec![att("hashA", "a.png"), att("hashB", "b.png")])).await.unwrap();
2305 let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2306 assert_eq!(got.len(), 2);
2307 assert_eq!((got[0].id.as_str(), got[1].id.as_str()), ("hashA", "hashB"), "att_index order");
2308 assert_eq!(got[0].name, "a.png");
2309 assert_eq!(got[0].size, 42);
2310 assert!(!got[0].downloaded);
2311
2312 crate::db::attachments::set_attachment_downloaded("m1", "hashA", true, "/tmp/a.png").unwrap();
2314 let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2315 assert!(got[0].downloaded && got[0].path == "/tmp/a.png");
2316 assert!(!got[1].downloaded, "sibling attachment untouched");
2317
2318 save_message("npub1att", &msg("m2", 1001, vec![att("hashA", "a-again.png")])).await.unwrap();
2320 let affected = crate::db::attachments::backfill_downloaded_by_hash("hashA", "/tmp/a.png", "m1").unwrap();
2321 assert_eq!(affected, vec!["m2".to_string()]);
2322 assert!(crate::db::attachments::get_attachments_for_event("m2").unwrap()[0].downloaded);
2323
2324 let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1att").unwrap();
2327 let views = get_message_views(chat_int, 10, 0).await.unwrap();
2328 let m1 = views.iter().find(|m| m.id == "m1").unwrap();
2329 assert_eq!(m1.attachments.len(), 2);
2330
2331 delete_event("m1").await.unwrap();
2333 assert!(crate::db::attachments::get_attachments_for_event("m1").unwrap().is_empty(), "ON DELETE CASCADE");
2334 }
2335
2336 #[tokio::test]
2339 async fn attachment_download_state_is_monotonic_across_resaves() {
2340 let (_tmp, _guard) = init_test_db();
2341 let att = |id: &str, downloaded: bool, path: &str| Attachment {
2342 id: id.into(), url: "u".into(), name: "f.png".into(), extension: "png".into(),
2343 size: 1, downloaded, path: path.into(), ..Default::default()
2344 };
2345 let msg = |atts: Vec<Attachment>| Message {
2346 id: "dl1".into(), content: String::new(), at: 1_000_000, mine: false,
2347 npub: Some("npub1s".into()), attachments: atts, ..Default::default()
2348 };
2349
2350 save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2352 crate::db::attachments::set_attachment_downloaded("dl1", "nonceid", true, "/tmp/f.png").unwrap();
2353
2354 save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2356 let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2357 assert!(got[0].downloaded && got[0].path == "/tmp/f.png", "re-delivery preserves the download");
2358
2359 save_message("npub1dl", &msg(vec![att("contenthash", true, "/tmp/f.png")])).await.unwrap();
2361 let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2362 assert_eq!(got[0].id, "contenthash", "hash rewritten nonce→content");
2363 assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2364
2365 save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2368 let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2369 assert_eq!(got[0].id, "contenthash", "content-hash key survives a later re-delivery");
2370 assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2371 }
2372
2373 #[tokio::test]
2376 async fn attachments_fall_back_to_legacy_tag_when_table_empty() {
2377 let (_tmp, _guard) = init_test_db();
2378 let a = Attachment {
2379 id: "tagonly".into(), url: "u".into(), name: "old.png".into(), extension: "png".into(),
2380 size: 7, downloaded: false, ..Default::default()
2381 };
2382 save_message("npub1old", &Message {
2383 id: "old1".into(), content: String::new(), at: 2_000_000, mine: false,
2384 npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2385 }).await.unwrap();
2386
2387 {
2389 let conn = crate::db::get_write_connection_guard_static().unwrap();
2390 conn.execute("DELETE FROM attachments WHERE event_id='old1'", []).unwrap();
2391 let inner = serde_json::to_string(&vec![a]).unwrap();
2392 let tags = serde_json::to_string(&vec![vec!["attachments".to_string(), inner]]).unwrap();
2393 conn.execute("UPDATE events SET tags=?1 WHERE id='old1'", rusqlite::params![tags]).unwrap();
2394 }
2395 assert!(crate::db::attachments::get_attachments_for_event("old1").unwrap().is_empty(), "table empty");
2396
2397 let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1old").unwrap();
2398 let views = get_message_views(chat_int, 10, 0).await.unwrap();
2399 let old = views.iter().find(|m| m.id == "old1").unwrap();
2400 assert_eq!(old.attachments.len(), 1, "attachment served from the legacy-tag fallback");
2401 assert_eq!(old.attachments[0].name, "old.png");
2402 }
2403
2404 #[tokio::test]
2407 async fn attachment_tag_strip_is_gated_on_backfill() {
2408 let (_tmp, _guard) = init_test_db();
2409 let a = Attachment { id: "h1".into(), extension: "png".into(), name: "f.png".into(), downloaded: false, ..Default::default() };
2410 let mk = |id: &str, secs: u64| Message {
2411 id: id.into(), content: String::new(), at: secs * 1000, mine: false,
2412 npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2413 };
2414 save_message("npub1s", &mk("bf", 1000)).await.unwrap();
2415 save_message("npub1s", &mk("unbf", 2000)).await.unwrap();
2416
2417 {
2418 let conn = crate::db::get_write_connection_guard_static().unwrap();
2419 let inner = serde_json::to_string(&vec![a.clone()]).unwrap();
2420 let with_tag = |ms: &str| serde_json::to_string(&vec![
2421 vec!["ms".to_string(), ms.to_string()],
2422 vec!["attachments".to_string(), inner.clone()],
2423 ]).unwrap();
2424 conn.execute("UPDATE events SET tags=?1 WHERE id='bf'", rusqlite::params![with_tag("5")]).unwrap();
2426 conn.execute("UPDATE events SET tags=?1 WHERE id='unbf'", rusqlite::params![with_tag("6")]).unwrap();
2427 conn.execute("DELETE FROM attachments WHERE event_id='unbf'", []).unwrap();
2428
2429 let events: Vec<(String, String)> = {
2431 let mut stmt = conn.prepare("SELECT id, tags FROM events WHERE tags LIKE '%attachments%' AND id IN (SELECT DISTINCT event_id FROM attachments)").unwrap();
2432 let m = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).unwrap();
2433 m.flatten().collect()
2434 };
2435 for (id, tj) in events {
2436 let mut tags: Vec<Vec<String>> = serde_json::from_str(&tj).unwrap();
2437 tags.retain(|t| t.first().map(|s| s.as_str()) != Some("attachments"));
2438 conn.execute("UPDATE events SET tags=?1 WHERE id=?2",
2439 rusqlite::params![serde_json::to_string(&tags).unwrap(), id]).unwrap();
2440 }
2441
2442 let bf: String = conn.query_row("SELECT tags FROM events WHERE id='bf'", [], |r| r.get(0)).unwrap();
2443 assert!(!bf.contains("attachments"), "backfilled event's attachments tag stripped");
2444 assert!(bf.contains("\"ms\""), "sibling ms tag survives the strip");
2445 let unbf: String = conn.query_row("SELECT tags FROM events WHERE id='unbf'", [], |r| r.get(0)).unwrap();
2446 assert!(unbf.contains("attachments"), "un-backfilled event keeps its tag (no table row)");
2447 }
2448
2449 let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1s").unwrap();
2451 let views = get_message_views(chat_int, 10, 0).await.unwrap();
2452 assert_eq!(views.iter().find(|m| m.id == "unbf").unwrap().attachments.len(), 1, "fallback still renders unbf");
2453 }
2454
2455 #[tokio::test]
2459 async fn compute_unread_anchor_covers_the_cases() {
2460 let (_tmp, _guard) = init_test_db();
2461 let mk = |id: &str, secs: u64, mine: bool| Message {
2462 id: id.into(), content: "x".into(), at: secs * 1000, mine,
2463 npub: (!mine).then(|| "npub1sender".to_string()),
2464 ..Default::default()
2465 };
2466 let unread = |chat: &'static str| async move {
2467 unread_counts().await.unwrap().get(chat).copied().unwrap_or(0)
2468 };
2469 let set_lr = |chat: &str, lr: &str| {
2470 let conn = crate::db::get_write_connection_guard_static().unwrap();
2471 conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2472 rusqlite::params![lr, chat]).unwrap();
2473 };
2474
2475 let a = "npub1anchorA";
2478 save_message(a, &mk("a_mine", 1000, true)).await.unwrap();
2479 for i in 0..8u64 { save_message(a, &mk(&format!("a{i}"), 2000 + i, false)).await.unwrap(); }
2480 assert_eq!(compute_unread_anchor(a).await.unwrap(), UnreadMark::Anchor("a6".into()));
2481 set_lr(a, "a6");
2482 assert_eq!(unread(a).await, 1, "A: newest contact message is the sole unread");
2483
2484 let b = "npub1anchorB";
2486 save_message(b, &mk("b0", 2000, false)).await.unwrap();
2487 save_message(b, &mk("b_mine", 2001, true)).await.unwrap();
2488 assert_eq!(compute_unread_anchor(b).await.unwrap(), UnreadMark::NoOp);
2489
2490 let c = "npub1anchorC";
2493 save_message(c, &mk("c0", 3000, false)).await.unwrap();
2494 save_message(c, &mk("c1", 3005, false)).await.unwrap();
2495 save_message(c, &mk("c2", 3005, false)).await.unwrap();
2496 assert_eq!(compute_unread_anchor(c).await.unwrap(), UnreadMark::Anchor("c0".into()));
2497 set_lr(c, "c0");
2498 assert_eq!(unread(c).await, 2, "C: same-second tail both count");
2499
2500 let d = "npub1anchorD";
2502 save_message(d, &mk("d0", 4000, false)).await.unwrap();
2503 assert_eq!(compute_unread_anchor(d).await.unwrap(), UnreadMark::Clear);
2504 set_lr(d, "");
2505 assert_eq!(unread(d).await, 1, "D: lone contact message surfaces");
2506
2507 let e = "npub1anchorE";
2509 save_message(e, &mk("e_mine", 5000, true)).await.unwrap();
2510 assert_eq!(compute_unread_anchor(e).await.unwrap(), UnreadMark::NoOp);
2511 }
2512
2513 #[tokio::test]
2521 async fn a_system_event_with_a_blank_conversation_id_is_refused() {
2522 let (_tmp, _guard) = init_test_db();
2523 let before = chat_row_count();
2524 for blank in ["", " "] {
2525 assert!(
2526 save_system_event_at("ev", blank, SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2527 .await
2528 .is_err(),
2529 "a blank conversation id must be refused, not minted"
2530 );
2531 }
2532 assert_eq!(chat_row_count(), before, "and no chat row is created");
2533 assert!(
2535 save_system_event_at("ev2", "real-chat", SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2536 .await
2537 .unwrap()
2538 );
2539 }
2540
2541 fn chat_row_count() -> i64 {
2542 let conn = crate::db::get_db_connection_guard_static().unwrap();
2543 conn.query_row("SELECT count(*) FROM chats", [], |r| r.get(0)).unwrap()
2544 }
2545
2546 #[tokio::test]
2547 async fn unread_clears_when_last_read_is_a_system_event() {
2548 let (_tmp, _guard) = init_test_db();
2549 let chat = "npub1sysevtdm";
2550 let mk = |id: &str, secs: u64| Message {
2551 id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2552 npub: Some("npub1sender".to_string()), ..Default::default()
2553 };
2554 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2555
2556 for i in 0..5u64 {
2559 save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap();
2560 }
2561 assert_eq!(unread().await, 5, "never-read backlog");
2562
2563 save_system_event_at("sysev", chat, SystemEventType::MemberJoined, "npubX", None, 2000, None, None).await.unwrap();
2565
2566 {
2568 let conn = crate::db::get_write_connection_guard_static().unwrap();
2569 conn.execute(
2570 "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2571 rusqlite::params!["sysev", chat],
2572 ).unwrap();
2573 }
2574 assert_eq!(unread().await, 0, "read marker on a system event still clears the badge");
2575 }
2576
2577 #[tokio::test]
2581 async fn deleting_a_message_adjusts_unread_without_wedging() {
2582 let (_tmp, _guard) = init_test_db();
2583 let chat = "npub1delunread";
2584 let mk = |id: &str, secs: u64| Message {
2585 id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2586 npub: Some("npub1sender".to_string()), ..Default::default()
2587 };
2588 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2589 let set_marker = |id: &str| {
2590 let conn = crate::db::get_write_connection_guard_static().unwrap();
2591 conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2592 rusqlite::params![id, chat]).unwrap();
2593 };
2594 let marker = || -> String {
2595 let conn = crate::db::get_db_connection_guard_static().unwrap();
2596 conn.query_row("SELECT last_read FROM chats WHERE chat_identifier = ?1",
2597 rusqlite::params![chat], |r| r.get::<_, String>(0)).unwrap()
2598 };
2599
2600 for i in 0..6u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap(); }
2602 set_marker("m1");
2603 assert_eq!(unread().await, 4, "m2..m5 unread");
2604
2605 delete_event("m3").await.unwrap();
2607 assert_eq!(unread().await, 3, "one unread deleted → badge minus one");
2608 assert_eq!(marker(), "m1", "deleting an unread message leaves the marker alone");
2609
2610 delete_event("m1").await.unwrap();
2612 assert_eq!(marker(), "m0", "marker retreats to the newest survivor before it");
2613 assert_eq!(unread().await, 3, "retreat keeps the count, no collapse to 99+");
2614
2615 delete_event("m0").await.unwrap();
2617 assert_eq!(marker(), "", "no earlier survivor → marker clears");
2618 assert_eq!(unread().await, 3, "cleared marker counts only the true unread survivors");
2619 }
2620
2621 #[tokio::test]
2625 async fn edit_event_folds_into_history_on_reload() {
2626 let (_tmp, _guard) = init_test_db();
2627 let chat = "channel_edit_fold";
2628 save_message(chat, &Message {
2629 id: "orig1".into(), content: "original".into(), at: 5_000_000,
2630 npub: Some("npub1author".into()), ..Default::default()
2631 }).await.unwrap();
2632
2633 let cid = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2634 let emoji = vec![crate::types::EmojiTag { shortcode: "wave".into(), url: "u/wave".into() }];
2635 save_edit_event("edit1", "orig1", "edited :wave:", &emoji, cid, None, "npub1author").await.unwrap();
2636
2637 let m = get_message_views(cid, 50, 0).await.unwrap()
2638 .into_iter().find(|m| m.id == "orig1").expect("message reloaded");
2639 assert!(m.edited, "folded edit sets the edited flag");
2640 let h = m.edit_history.as_ref().expect("history reconstructed from the edit event");
2641 assert_eq!(h.len(), 2, "original + one edit");
2642 assert_eq!(h[0].content, "original");
2643 assert_eq!(h[1].content, "edited :wave:");
2644 assert_eq!(m.content, "edited :wave:", "latest revision is the displayed content");
2645 assert_eq!(m.emoji_tags.len(), 1, "the edit's own emoji folds onto the message");
2646 assert_eq!(m.emoji_tags[0].shortcode, "wave");
2647 }
2648
2649 #[tokio::test]
2653 async fn batched_save_matches_single_save_shape() {
2654 let (_tmp, _guard) = init_test_db();
2655 let chat = "channel_batch1";
2656 let att = crate::types::Attachment {
2657 id: "atthash1".into(), extension: "png".into(), name: "a.png".into(),
2658 url: "https://x/att".into(), downloaded: false, ..Default::default()
2659 };
2660 let reaction = Reaction {
2661 id: "react_b1".into(), reference_id: "b1".into(),
2662 author_id: "npub1reactor".into(), emoji: "👍".into(), emoji_url: None,
2663 };
2664 let msgs: Vec<Message> = (0..5u64).map(|i| Message {
2666 id: format!("b{i}"), content: format!("c{i}"), at: 7_000_000,
2667 npub: Some("npub1sender".into()),
2668 attachments: if i == 2 { vec![att.clone()] } else { Vec::new() },
2669 reactions: if i == 1 { vec![reaction.clone()] } else { Vec::new() },
2670 ..Default::default()
2671 }).collect();
2672 let refs: Vec<&Message> = msgs.iter().collect();
2673
2674 let saved = save_messages_batch(chat, &refs).await.unwrap();
2675 assert_eq!(saved, 5, "every message written");
2676
2677 for i in 0..5u64 {
2678 assert!(event_exists(&format!("b{i}")).unwrap(), "b{i} row exists");
2679 }
2680 assert!(event_exists("react_b1").unwrap(), "reaction landed as its own kind-7 row");
2681 let atts = crate::db::attachments::get_attachments_for_event("b2").unwrap();
2682 assert_eq!(atts.len(), 1, "attachment row committed with its event");
2683 assert_eq!(atts[0].id, "atthash1");
2684
2685 let conn = crate::db::get_db_connection_guard_static().unwrap();
2687 let ids: Vec<String> = conn
2688 .prepare("SELECT id FROM events WHERE id IN ('b0','b1','b2','b3','b4') ORDER BY rowid")
2689 .unwrap()
2690 .query_map([], |r| r.get(0)).unwrap()
2691 .flatten().collect();
2692 assert_eq!(ids, vec!["b0", "b1", "b2", "b3", "b4"], "insert order preserves the rowid tiebreak");
2693 }
2694
2695 fn link_channel_to_community(chat: &str, community: &str) {
2697 let conn = crate::db::get_write_connection_guard_static().unwrap();
2698 conn.execute(
2699 "INSERT OR REPLACE INTO community_channels \
2700 (channel_id, community_id, channel_key, epoch, name, created_at) \
2701 VALUES (?1, ?2, x'00', 0, 'general', 0)",
2702 rusqlite::params![chat, community],
2703 )
2704 .unwrap();
2705 }
2706
2707 fn ban_pair() -> (String, String) {
2710 let k = nostr_sdk::prelude::Keys::generate();
2711 use nostr_sdk::prelude::ToBech32;
2712 (k.public_key().to_hex(), k.public_key().to_bech32().unwrap())
2713 }
2714
2715 #[tokio::test]
2717 async fn a_banned_authors_messages_are_not_returned() {
2718 let (_tmp, _guard) = init_test_db();
2719 let chat = "channel_ban_hide";
2720 link_channel_to_community(chat, "comm_hide");
2721 let (raider_hex, raider) = ban_pair();
2722 let (_, good) = ban_pair();
2723 for (id, npub, at) in [("keep1", &good, 1_000u64), ("spam1", &raider, 2_000), ("keep2", &good, 3_000)] {
2724 let msg = Message { id: id.into(), content: "hi".into(), at, npub: Some(npub.clone()), ..Default::default() };
2725 save_message(chat, &msg).await.unwrap();
2726 }
2727 crate::db::community::set_community_banlist("comm_hide", &[raider_hex], 1).unwrap();
2728
2729 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2730 let got = get_events(chat_id, None, 50, 0).await.unwrap();
2731 let ids: Vec<&str> = got.iter().map(|e| e.id.as_str()).collect();
2732 assert!(!ids.contains(&"spam1"), "a banned author's existing message must not be returned: {ids:?}");
2733 assert!(ids.contains(&"keep1") && ids.contains(&"keep2"), "everyone else survives: {ids:?}");
2734 }
2735
2736 #[tokio::test]
2740 async fn a_wall_of_banned_events_does_not_shorten_the_page() {
2741 let (_tmp, _guard) = init_test_db();
2742 let chat = "channel_ban_page";
2743 link_channel_to_community(chat, "comm_page");
2744 let (raider_hex, raider) = ban_pair();
2747 let (_, good) = ban_pair();
2748 for i in 0..20u64 {
2749 let msg = Message { id: format!("real{i}"), content: "hi".into(), at: 1_000 + i, npub: Some(good.clone()), ..Default::default() };
2750 save_message(chat, &msg).await.unwrap();
2751 }
2752 for i in 0..40u64 {
2753 let msg = Message { id: format!("spam{i}"), content: "buy".into(), at: 5_000 + i, npub: Some(raider.clone()), ..Default::default() };
2754 save_message(chat, &msg).await.unwrap();
2755 }
2756 crate::db::community::set_community_banlist("comm_page", &[raider_hex], 1).unwrap();
2757
2758 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2759 let page = get_events(chat_id, None, 20, 0).await.unwrap();
2760 assert_eq!(page.len(), 20, "a page of 20 must return 20 real messages, not what is left after filtering");
2761 assert!(page.iter().all(|e| e.npub.as_deref() == Some(good.as_str())), "no banned author leaks into the page");
2762 }
2763
2764 #[tokio::test]
2767 async fn the_kind_filtered_page_excludes_banned_authors_too() {
2768 let (_tmp, _guard) = init_test_db();
2769 let chat = "channel_ban_kinds";
2770 link_channel_to_community(chat, "comm_kinds");
2771 let (raider_hex, raider) = ban_pair();
2772 let (_, good) = ban_pair();
2773 for i in 0..12u64 {
2774 let msg = Message { id: format!("real{i}"), content: "hi".into(), at: 1_000 + i, npub: Some(good.clone()), ..Default::default() };
2775 save_message(chat, &msg).await.unwrap();
2776 }
2777 for i in 0..30u64 {
2778 let msg = Message { id: format!("spam{i}"), content: "buy".into(), at: 5_000 + i, npub: Some(raider.clone()), ..Default::default() };
2779 save_message(chat, &msg).await.unwrap();
2780 }
2781 crate::db::community::set_community_banlist("comm_kinds", &[raider_hex], 1).unwrap();
2782
2783 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2784 let kinds = [event_kind::PRIVATE_DIRECT_MESSAGE];
2785 let page = get_events(chat_id, Some(&kinds), 12, 0).await.unwrap();
2786 assert_eq!(page.len(), 12, "the kind-filtered page must fill with real messages");
2787 assert!(page.iter().all(|e| e.npub.as_deref() == Some(good.as_str())), "no banned author in a kind-filtered page");
2788 }
2789
2790 #[tokio::test]
2794 async fn a_banned_members_join_event_is_hidden_with_their_messages() {
2795 let (_tmp, _guard) = init_test_db();
2796 let chat = "channel_ban_join";
2797 link_channel_to_community(chat, "comm_join");
2798 let (raider_hex, raider) = ban_pair();
2799
2800 save_system_event_at("joined_evt", chat, SystemEventType::MemberJoined, &raider, None, 4_000, None, None)
2801 .await
2802 .unwrap();
2803 let msg = Message { id: "spam_after_join".into(), content: "buy".into(), at: 5_000, npub: Some(raider.clone()), ..Default::default() };
2804 save_message(chat, &msg).await.unwrap();
2805 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2806 assert_eq!(get_events(chat_id, None, 50, 0).await.unwrap().len(), 2, "both rows are on record first");
2808
2809 crate::db::community::set_community_banlist("comm_join", &[raider_hex], 1).unwrap();
2810
2811 let got = get_events(chat_id, None, 50, 0).await.unwrap();
2812 let ids: Vec<&str> = got.iter().map(|e| e.id.as_str()).collect();
2813 assert!(!ids.contains(&"joined_evt"), "the join row goes with them: {ids:?}");
2814 assert!(!ids.contains(&"spam_after_join"), "and so does what they said: {ids:?}");
2815 }
2816
2817 #[tokio::test]
2820 async fn a_banned_authors_messages_do_not_badge_the_channel() {
2821 let (_tmp, _guard) = init_test_db();
2822 let chat = "channel_ban_unread";
2823 link_channel_to_community(chat, "comm_unread");
2824 let (raider_hex, raider) = ban_pair();
2825 let (_, good) = ban_pair();
2826
2827 let real = Message { id: "real_unread".into(), content: "hi".into(), at: 1_000, npub: Some(good.clone()), ..Default::default() };
2828 save_message(chat, &real).await.unwrap();
2829 for i in 0..5u64 {
2830 let spam = Message { id: format!("spam_unread{i}"), content: "buy".into(), at: 2_000 + i, npub: Some(raider.clone()), ..Default::default() };
2831 save_message(chat, &spam).await.unwrap();
2832 }
2833 assert_eq!(unread_count_for_chat(chat).await.unwrap(), 6, "before the ban everything counts");
2834
2835 crate::db::community::set_community_banlist("comm_unread", &[raider_hex], 1).unwrap();
2836
2837 assert_eq!(
2838 unread_count_for_chat(chat).await.unwrap(), 1,
2839 "only the message that still renders is counted"
2840 );
2841 let all = unread_counts().await.unwrap();
2842 assert_eq!(all.get(chat).copied().unwrap_or(0), 1, "and the all-chats sweep agrees");
2843 }
2844
2845 #[tokio::test]
2849 async fn a_banned_members_join_line_is_dropped_from_the_system_feed() {
2850 let (_tmp, _guard) = init_test_db();
2851 let chat = "channel_ban_sysfeed";
2852 link_channel_to_community(chat, "comm_sysfeed");
2853 let (raider_hex, raider) = ban_pair();
2854 let (_, good) = ban_pair();
2855
2856 for (id, who) in [("join_good", &good), ("join_raider", &raider)] {
2857 save_system_event_at(id, chat, SystemEventType::MemberJoined, who, None, 1_000, None, None)
2858 .await
2859 .unwrap();
2860 }
2861 assert_eq!(get_system_events_for_chat(chat).unwrap().len(), 2, "both arrivals are on record");
2862
2863 crate::db::community::set_community_banlist("comm_sysfeed", &[raider_hex], 1).unwrap();
2864
2865 let feed = get_system_events_for_chat(chat).unwrap();
2866 let ids: Vec<&str> = feed.iter().map(|e| e.id.as_str()).collect();
2867 assert_eq!(ids, vec!["join_good"], "the banned member's arrival goes with their messages");
2868 }
2869
2870 #[tokio::test]
2878 async fn a_moderated_delete_survives_the_message_being_re_ingested() {
2879 let (_tmp, _guard) = init_test_db();
2880 let chat = "channel_delete_resync";
2881 let (_, author) = ban_pair();
2882 let msg = Message { id: "moderated_evt".into(), content: "nsfw".into(), at: 1_000, npub: Some(author.clone()), ..Default::default() };
2883 save_message(chat, &msg).await.unwrap();
2884
2885 crate::state::note_message_deleted("moderated_evt");
2887 add_message_tombstone("moderated_evt").unwrap();
2888 delete_event("moderated_evt").await.unwrap();
2889
2890 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2891 assert!(get_events(chat_id, None, 50, 0).await.unwrap().is_empty(), "removed on the spot");
2892
2893 assert!(
2895 crate::state::was_message_deleted("moderated_evt"),
2896 "the refusal outlives the row, so the re-ingest is dropped rather than resurrecting it"
2897 );
2898 }
2899
2900 #[tokio::test]
2904 async fn the_backscroll_window_excludes_banned_authors() {
2905 let (_tmp, _guard) = init_test_db();
2906 let chat = "channel_ban_window";
2907 link_channel_to_community(chat, "comm_window");
2908 let (raider_hex, raider) = ban_pair();
2909 let (_, good) = ban_pair();
2910
2911 for i in 0..10u64 {
2912 let m = Message { id: format!("w_real{i}"), content: "hi".into(), at: 1_000 + i, npub: Some(good.clone()), ..Default::default() };
2913 save_message(chat, &m).await.unwrap();
2914 let s = Message { id: format!("w_spam{i}"), content: "buy".into(), at: 1_500 + i, npub: Some(raider.clone()), ..Default::default() };
2915 save_message(chat, &s).await.unwrap();
2916 }
2917 crate::db::community::set_community_banlist("comm_window", &[raider_hex], 1).unwrap();
2918
2919 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2920 let win = get_messages_around(chat_id, "w_real5", 20, 20).await.unwrap();
2921 assert!(!win.is_empty(), "the window still returns the real messages");
2922 assert!(
2923 win.iter().all(|m| m.npub.as_deref() == Some(good.as_str())),
2924 "no banned author reachable by jumping to a message near them",
2925 );
2926 }
2927
2928 #[tokio::test]
2930 async fn a_dm_is_unaffected_by_the_ban_filter() {
2931 let (_tmp, _guard) = init_test_db();
2932 let chat = "npub1peer_dm";
2933 let msg = Message { id: "dm1".into(), content: "hi".into(), at: 1_000, npub: Some("npub1peer".into()), ..Default::default() };
2934 save_message(chat, &msg).await.unwrap();
2935 let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2936 assert_eq!(get_events(chat_id, None, 50, 0).await.unwrap().len(), 1, "a DM resolves no community and filters nothing");
2937 }
2938
2939 #[tokio::test]
2942 async fn batched_resave_preserves_wrapper_and_dedups_reactions() {
2943 let (_tmp, _guard) = init_test_db();
2944 let chat = "channel_batch2";
2945 let reaction = Reaction {
2946 id: "react_rs".into(), reference_id: "rs1".into(),
2947 author_id: "npub1reactor".into(), emoji: "🔥".into(), emoji_url: None,
2948 };
2949 let mut msg = Message {
2950 id: "rs1".into(), content: "hello".into(), at: 8_000_000,
2951 npub: Some("npub1sender".into()),
2952 wrapper_event_id: Some("wrap_original".into()),
2953 reactions: vec![reaction],
2954 ..Default::default()
2955 };
2956 save_message(chat, &msg).await.unwrap();
2957
2958 msg.wrapper_event_id = None;
2960 let saved = save_messages_batch(chat, &[&msg]).await.unwrap();
2961 assert_eq!(saved, 1);
2962
2963 let conn = crate::db::get_db_connection_guard_static().unwrap();
2964 let wrapper: Option<String> = conn.query_row(
2965 "SELECT wrapper_event_id FROM events WHERE id = 'rs1'", [], |r| r.get(0),
2966 ).unwrap();
2967 assert_eq!(wrapper.as_deref(), Some("wrap_original"), "COALESCE keeps the known wrapper");
2968 let reaction_rows: i64 = conn.query_row(
2969 "SELECT COUNT(*) FROM events WHERE id = 'react_rs'", [], |r| r.get(0),
2970 ).unwrap();
2971 assert_eq!(reaction_rows, 1, "reaction row not duplicated by the re-save");
2972 }
2973
2974 #[tokio::test]
2978 async fn batching_persist_flushes_multi_chat_and_drains_into_the_account_that_filled_it() {
2979 let (_tmp, _guard) = init_test_db();
2980 let handler = crate::event_handler::NoOpEventHandler;
2981 let batcher = crate::event_handler::BatchingPersist::new(&handler);
2982
2983 let mk = |id: &str, npub: &str| Message {
2984 id: id.into(), content: "x".into(), at: 9_000_000,
2985 npub: Some(npub.into()), ..Default::default()
2986 };
2987 let seed = |chat: &str, m: &Message| {
2990 let m = m.clone();
2991 let chat = chat.to_string();
2992 async move {
2993 let mut st = crate::state::STATE.lock().await;
2994 st.add_message_to_participant(&chat, &m);
2995 }
2996 };
2997 let wrap_a1 = ([0xA1u8; 32], 111u64);
2998 let wrap_b1 = ([0xB1u8; 32], 222u64);
2999 let a1 = mk("bp_a1", "npub1chata");
3000 let b1 = mk("bp_b1", "npub1chatb");
3001 let a2 = mk("bp_a2", "npub1chata");
3002 seed("npub1chata", &a1).await;
3003 seed("npub1chatb", &b1).await;
3004 seed("npub1chata", &a2).await;
3005
3006 use crate::event_handler::InboundEventHandler;
3007 assert!(batcher.buffer_persist("npub1chata", &a1, Some(wrap_a1)), "batcher owns the persist");
3008 assert!(batcher.buffer_persist("npub1chatb", &b1, Some(wrap_b1)));
3009 assert!(batcher.buffer_persist("npub1chata", &a2, None));
3010 assert_eq!(batcher.buffered(), 3);
3011
3012 let ledgered = |bytes: [u8; 32]| {
3013 let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
3014 crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
3015 };
3016 assert!(!ledgered(wrap_a1.0), "wrapper unledgered while its message sits buffered");
3017
3018 assert_eq!(batcher.flush().await, 3, "all buffered messages written");
3019 assert_eq!(batcher.buffered(), 0);
3020 assert!(event_exists("bp_a1").unwrap() && event_exists("bp_b1").unwrap() && event_exists("bp_a2").unwrap());
3021 assert!(ledgered(wrap_a1.0) && ledgered(wrap_b1.0), "wrappers ledgered with the flush");
3022 let a = crate::db::id_cache::get_chat_id_by_identifier("npub1chata").unwrap();
3023 let b = crate::db::id_cache::get_chat_id_by_identifier("npub1chatb").unwrap();
3024 assert_ne!(a, b, "rows grouped under their own chats");
3025
3026 let filled_under = crate::db::current_session();
3030 let late = mk("bp_late", "npub1chata");
3031 seed("npub1chata", &late).await;
3032 let wrap_late = ([0x5Eu8; 32], 333u64);
3033 batcher.buffer_persist("npub1chata", &late, Some(wrap_late));
3034
3035 let next = make_test_npub(TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
3036 std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&next)).unwrap();
3037 crate::db::set_current_account(next.clone()).unwrap();
3038 crate::db::init_database(&next).unwrap();
3039
3040 assert_eq!(batcher.flush().await, 1, "the buffer still drains");
3041 assert!(
3042 crate::db::with_session(filled_under, async {
3043 event_exists("bp_late").unwrap() && ledgered(wrap_late.0)
3044 })
3045 .await,
3046 "written to the account that received it, and its wrapper ledgered there"
3047 );
3048 assert!(!event_exists("bp_late").unwrap(), "the account swapped in never sees it");
3049 assert_eq!(batcher.buffered(), 0, "and the buffer is empty either way");
3050 }
3051
3052 #[tokio::test]
3059 async fn a_delete_tombstone_survives_a_restart() {
3060 let (_tmp, _guard) = init_test_db();
3064 let msg = Message {
3065 id: "resurrect-me".into(), content: "note".into(), at: 1_000_000,
3066 mine: true, ..Default::default()
3067 };
3068 save_message("npub1notes", &msg).await.unwrap();
3069 add_message_tombstone("resurrect-me").unwrap();
3070 delete_event("resurrect-me").await.unwrap();
3071
3072 crate::db::close_database();
3074 assert!(!crate::state::was_message_deleted("resurrect-me"), "session set cleared");
3075 crate::state::seed_message_tombstones(load_message_tombstones().unwrap());
3077 assert!(crate::state::was_message_deleted("resurrect-me"), "the durable tombstone re-seeds");
3078 }
3079
3080 #[tokio::test]
3081 async fn buffered_message_deleted_before_flush_never_persists() {
3082 let (_tmp, _guard) = init_test_db();
3083 let handler = crate::event_handler::NoOpEventHandler;
3084 let batcher = crate::event_handler::BatchingPersist::new(&handler);
3085 use crate::event_handler::InboundEventHandler;
3086
3087 let chat = "npub1delchat";
3088 let mk = |id: &str| Message {
3089 id: id.into(), content: "x".into(), at: 9_500_000,
3090 npub: Some(chat.into()), ..Default::default()
3091 };
3092 let ledgered = |bytes: [u8; 32]| {
3093 let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
3094 crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
3095 };
3096
3097 let m1 = mk("del_sametask");
3100 {
3101 let mut st = crate::state::STATE.lock().await;
3102 st.add_message_to_participant(chat, &m1);
3103 }
3104 batcher.buffer_persist(chat, &m1, Some(([0xD1u8; 32], 444)));
3105 {
3106 let mut st = crate::state::STATE.lock().await;
3107 st.remove_message("del_sametask");
3108 }
3109 batcher.on_message_deleted(chat, "del_sametask");
3110 assert_eq!(batcher.buffered(), 0, "deletion purges the buffered target");
3111
3112 let m2 = mk("del_crosstask");
3115 {
3116 let mut st = crate::state::STATE.lock().await;
3117 st.add_message_to_participant(chat, &m2);
3118 }
3119 batcher.buffer_persist(chat, &m2, Some(([0xD2u8; 32], 555)));
3120 crate::state::note_message_deleted("del_crosstask");
3121
3122 let m3 = mk("evicted_ok");
3124 let wrap_evicted = ([0xE0u8; 32], 666u64);
3125 {
3126 let mut st = crate::state::STATE.lock().await;
3127 st.add_message_to_participant(chat, &m3);
3128 st.remove_message("evicted_ok");
3129 }
3130 batcher.buffer_persist(chat, &m3, Some(wrap_evicted));
3131
3132 assert_eq!(batcher.flush().await, 1, "tombstoned target dropped, evicted message written");
3133 assert!(!event_exists("del_sametask").unwrap(), "purged message never persisted");
3134 assert!(!event_exists("del_crosstask").unwrap(), "tombstoned message never persisted");
3135 assert!(event_exists("evicted_ok").unwrap(), "evicted-but-not-deleted message persisted");
3136 assert!(ledgered(wrap_evicted.0), "evicted message's wrapper ledgered with it");
3137 }
3138}
3139
3140type WrapContext = (Option<String>, Vec<Vec<String>>);
3144
3145pub fn get_event_wrap_context(event_id: &str) -> Result<Option<WrapContext>, String> {
3146 let conn = super::get_db_connection_guard_static()?;
3147 let row: Option<(Option<String>, String)> = conn
3148 .query_row(
3149 "SELECT wrapper_event_id, tags FROM events WHERE id = ?1",
3150 rusqlite::params![event_id],
3151 |r| Ok((r.get(0)?, r.get(1)?)),
3152 )
3153 .optional()
3154 .map_err(|e| format!("get wrap context: {e}"))?;
3155 Ok(row.map(|(wrap, tags_json)| {
3156 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
3157 (wrap, tags)
3158 }))
3159}