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