1use crate::stored_event::{StoredEvent, event_kind};
4use rusqlite::OptionalExtension;
5use crate::crypto::maybe_encrypt;
6use crate::types::{Message, Attachment, Reaction};
7
8pub async fn save_event(event: &StoredEvent) -> Result<(), String> {
14 let conn = super::get_write_connection_guard_static()?;
15
16 let tags_json = serde_json::to_string(&event.tags)
17 .unwrap_or_else(|_| "[]".to_string());
18
19 let content = if event.kind == event_kind::CHAT_MESSAGE
21 || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
22 || event.kind == event_kind::MESSAGE_EDIT
23 {
24 maybe_encrypt(event.content.clone()).await
25 } else {
26 event.content.clone()
27 };
28
29 conn.execute(
30 r#"
31 INSERT OR REPLACE INTO events (
32 id, kind, chat_id, user_id, content, tags, reference_id,
33 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata
34 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
35 COALESCE(?13, (SELECT wrapper_event_id FROM events WHERE id = ?1)),
36 ?14, ?15)
37 "#,
38 rusqlite::params![
39 event.id,
40 event.kind as i32,
41 event.chat_id,
42 event.user_id,
43 content,
44 tags_json,
45 event.reference_id,
46 event.created_at as i64,
47 event.received_at as i64,
48 event.mine as i32,
49 event.pending as i32,
50 event.failed as i32,
51 event.wrapper_event_id,
52 event.npub,
53 event.preview_metadata,
54 ],
55 ).map_err(|e| format!("Failed to save event: {}", e))?;
56
57 Ok(())
58}
59
60pub fn event_exists(event_id: &str) -> Result<bool, String> {
62 let conn = super::get_db_connection_guard_static()?;
63 conn.query_row(
64 "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
65 rusqlite::params![event_id],
66 |row| row.get(0),
67 ).map_err(|e| format!("Failed to check event existence: {}", e))
68}
69
70pub async fn save_reaction_event(
72 reaction: &Reaction,
73 chat_id: i64,
74 user_id: Option<i64>,
75 mine: bool,
76 wrapper_event_id: Option<String>,
77) -> Result<(), String> {
78 let mut tags: Vec<Vec<String>> = vec![
82 vec!["e".to_string(), reaction.reference_id.clone()],
83 ];
84 if let Some(url) = &reaction.emoji_url {
85 if reaction.emoji.starts_with(':') && reaction.emoji.ends_with(':') && reaction.emoji.len() >= 3 {
86 let shortcode = &reaction.emoji[1..reaction.emoji.len() - 1];
87 if !shortcode.is_empty() && !url.is_empty() {
88 tags.push(vec!["emoji".to_string(), shortcode.to_string(), url.clone()]);
89 }
90 }
91 }
92 let event = StoredEvent {
93 id: reaction.id.clone(),
94 kind: event_kind::REACTION,
95 chat_id,
96 user_id,
97 content: reaction.emoji.clone(),
98 tags,
99 reference_id: Some(reaction.reference_id.clone()),
100 created_at: std::time::SystemTime::now()
101 .duration_since(std::time::UNIX_EPOCH)
102 .map(|d| d.as_secs()).unwrap_or(0),
103 received_at: std::time::SystemTime::now()
104 .duration_since(std::time::UNIX_EPOCH)
105 .map(|d| d.as_millis() as u64).unwrap_or(0),
106 mine,
107 pending: false,
108 failed: false,
109 wrapper_event_id,
110 npub: Some(reaction.author_id.clone()),
111 preview_metadata: None,
112 };
113 save_event(&event).await
114}
115
116pub async fn save_message(chat_id: &str, message: &Message) -> Result<(), String> {
125 let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
126
127 let user_int_id = if let Some(ref npub_str) = message.npub {
128 super::id_cache::get_or_create_user_id(npub_str)?
129 } else {
130 None
131 };
132
133 let event = message_to_stored_event(message, chat_int_id, user_int_id);
134 save_event(&event).await?;
135
136 for reaction in &message.reactions {
138 if !event_exists(&reaction.id)? {
139 let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
140 let is_mine = super::get_current_account()
141 .map(|npub| reaction.author_id == npub)
142 .unwrap_or(false);
143 save_reaction_event(reaction, chat_int_id, user_id, is_mine, None).await?;
144 }
145 }
146
147 Ok(())
148}
149
150fn message_to_stored_event(message: &Message, chat_id: i64, user_id: Option<i64>) -> StoredEvent {
152 let kind = if !message.attachments.is_empty() {
153 event_kind::FILE_ATTACHMENT
154 } else {
155 event_kind::PRIVATE_DIRECT_MESSAGE
156 };
157
158 let mut tags: Vec<Vec<String>> = Vec::new();
159
160 let ms = message.at % 1000;
162 if ms > 0 {
163 tags.push(vec!["ms".to_string(), ms.to_string()]);
164 }
165
166 if !message.replied_to.is_empty() {
168 tags.push(vec![
169 "e".to_string(),
170 message.replied_to.clone(),
171 "".to_string(),
172 "reply".to_string(),
173 ]);
174 }
175
176 if !message.attachments.is_empty() {
178 if let Ok(json) = serde_json::to_string(&message.attachments) {
179 tags.push(vec!["attachments".to_string(), json]);
180 }
181 }
182
183 for et in &message.emoji_tags {
186 tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
187 }
188
189 let preview_metadata = message.preview_metadata.as_ref()
190 .and_then(|m| serde_json::to_string(m).ok());
191
192 StoredEvent {
193 id: message.id.clone(),
194 kind,
195 chat_id,
196 user_id,
197 content: message.content.clone(),
198 tags,
199 reference_id: None,
200 created_at: message.at / 1000,
201 received_at: std::time::SystemTime::now()
202 .duration_since(std::time::UNIX_EPOCH)
203 .map(|d| d.as_millis() as u64)
204 .unwrap_or(0),
205 mine: message.mine,
206 pending: message.pending,
207 failed: message.failed,
208 wrapper_event_id: message.wrapper_event_id.clone(),
209 npub: message.npub.clone(),
210 preview_metadata,
211 }
212}
213
214pub async fn save_pivx_payment_event(
216 conversation_id: &str,
217 mut event: StoredEvent,
218) -> Result<(), String> {
219 event.chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
220 save_event(&event).await
221}
222
223pub async fn save_system_event_by_id(
226 event_id: &str,
227 conversation_id: &str,
228 event_type: crate::stored_event::SystemEventType,
229 member_npub: &str,
230 member_name: Option<&str>,
231) -> Result<bool, String> {
232 let now_secs = std::time::SystemTime::now()
233 .duration_since(std::time::UNIX_EPOCH)
234 .map(|d| d.as_secs()).unwrap_or(0);
235 save_system_event_at(event_id, conversation_id, event_type, member_npub, member_name, now_secs, None, None).await
236}
237
238pub async fn save_system_event_at(
242 event_id: &str,
243 conversation_id: &str,
244 event_type: crate::stored_event::SystemEventType,
245 member_npub: &str,
246 member_name: Option<&str>,
247 created_at_secs: u64,
248 invited_by: Option<&str>,
251 invited_label: Option<&str>,
252) -> Result<bool, String> {
253 let chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
254
255 let now_secs = std::time::SystemTime::now()
256 .duration_since(std::time::UNIX_EPOCH)
257 .map(|d| d.as_secs()).unwrap_or(0);
258 let created_at = created_at_secs.min(now_secs);
260
261 let display_name = member_name.unwrap_or(member_npub);
262 let content = event_type.display_message(display_name);
263
264 let mut tags: Vec<Vec<String>> = vec![
265 vec!["d".to_string(), "system-event".to_string()],
266 vec!["event-type".to_string(), event_type.as_u8().to_string()],
267 vec!["member".to_string(), member_npub.to_string()],
268 ];
269 if let Some(by) = invited_by {
270 tags.push(vec!["invited-by".to_string(), by.to_string()]);
271 if let Some(l) = invited_label.filter(|l| !l.is_empty()) {
272 tags.push(vec!["invited-label".to_string(), l.to_string()]);
273 }
274 }
275 let tags_json = serde_json::to_string(&tags)
276 .map_err(|e| format!("Failed to serialize tags: {}", e))?;
277
278 let conn = super::get_write_connection_guard_static()?;
279 let rows = conn.execute(
280 r#"INSERT OR IGNORE INTO events (
281 id, kind, chat_id, user_id, content, tags, reference_id,
282 created_at, received_at, mine, pending, failed, wrapper_event_id, npub
283 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
284 rusqlite::params![
285 event_id,
286 event_kind::APPLICATION_SPECIFIC as i32,
287 chat_id, None::<i64>, content, tags_json, None::<String>,
288 created_at as i64, now_secs as i64,
289 0, 0, 0, None::<String>, member_npub,
290 ],
291 ).map_err(|e| format!("Failed to save system event: {}", e))?;
292
293 Ok(rows > 0)
294}
295
296pub async fn save_edit_event(
298 edit_id: &str,
299 message_id: &str,
300 new_content: &str,
301 emoji_tags: &[crate::types::EmojiTag],
302 chat_id: i64,
303 user_id: Option<i64>,
304 npub: &str,
305) -> Result<(), String> {
306 let now = std::time::SystemTime::now()
307 .duration_since(std::time::UNIX_EPOCH).unwrap();
308
309 let mut tags = vec![
312 vec!["e".to_string(), message_id.to_string(), "".to_string(), "edit".to_string()],
313 ];
314 for et in emoji_tags {
315 tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
316 }
317
318 let event = StoredEvent {
319 id: edit_id.to_string(),
320 kind: event_kind::MESSAGE_EDIT,
321 chat_id,
322 user_id,
323 content: new_content.to_string(),
324 tags,
325 reference_id: Some(message_id.to_string()),
326 created_at: now.as_secs(),
327 received_at: now.as_millis() as u64,
328 mine: true,
329 pending: false,
330 failed: false,
331 wrapper_event_id: None,
332 npub: Some(npub.to_string()),
333 preview_metadata: None,
334 };
335
336 save_event(&event).await
337}
338
339pub async fn delete_event(event_id: &str) -> Result<(), String> {
341 let conn = super::get_write_connection_guard_static()?;
342 conn.execute(
343 "DELETE FROM events WHERE id = ?1",
344 rusqlite::params![event_id],
345 ).map_err(|e| format!("Failed to delete event: {}", e))?;
346 Ok(())
347}
348
349pub fn event_author(event_id: &str) -> Result<Option<String>, String> {
352 let conn = match super::get_db_connection_guard_static() {
353 Ok(c) => c,
354 Err(_) => return Ok(None),
355 };
356 conn.query_row(
357 "SELECT npub FROM events WHERE id = ?1",
358 rusqlite::params![event_id],
359 |row| row.get::<_, Option<String>>(0),
360 )
361 .optional()
362 .map(|o| o.flatten())
363 .map_err(|e| format!("Failed to read event author: {}", e))
364}
365
366pub fn message_exists_in_db(message_id: &str) -> Result<bool, String> {
368 let conn = match super::get_db_connection_guard_static() {
369 Ok(c) => c,
370 Err(_) => return Ok(false),
371 };
372 conn.query_row(
373 "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
374 rusqlite::params![message_id],
375 |row| row.get(0),
376 ).map_err(|e| format!("Failed to check event existence: {}", e))
377}
378
379pub fn wrapper_event_exists(wrapper_event_id: &str) -> Result<bool, String> {
381 let conn = match super::get_db_connection_guard_static() {
382 Ok(c) => c,
383 Err(_) => return Ok(false),
384 };
385 conn.query_row(
386 "SELECT EXISTS(SELECT 1 FROM events WHERE wrapper_event_id = ?1)",
387 rusqlite::params![wrapper_event_id],
388 |row| row.get(0),
389 ).map_err(|e| format!("Failed to check wrapper event existence: {}", e))
390}
391
392pub fn update_wrapper_event_id(event_id: &str, wrapper_event_id: &str) -> Result<bool, String> {
395 let conn = match super::get_write_connection_guard_static() {
396 Ok(c) => c,
397 Err(_) => return Ok(false),
398 };
399 let rows = conn.execute(
400 "UPDATE events SET wrapper_event_id = ?1 WHERE id = ?2 AND (wrapper_event_id IS NULL OR wrapper_event_id = '')",
401 rusqlite::params![wrapper_event_id, event_id],
402 ).map_err(|e| format!("Failed to update wrapper event ID: {}", e))?;
403 Ok(rows > 0)
404}
405
406pub fn get_chat_message_count(chat_id: i64) -> Result<usize, String> {
408 let conn = super::get_db_connection_guard_static()?;
409 let count: i64 = conn.query_row(
410 "SELECT COUNT(*) FROM events WHERE chat_id = ?1 AND kind IN (14, 15)",
411 rusqlite::params![chat_id],
412 |row| row.get(0),
413 ).map_err(|e| format!("Failed to count messages: {}", e))?;
414 Ok(count as usize)
415}
416
417pub fn get_pivx_payments_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
419 let conn = super::get_db_connection_guard_static()?;
420 let chat_id: i64 = conn.query_row(
421 "SELECT id FROM chats WHERE chat_identifier = ?1",
422 rusqlite::params![conversation_id], |row| row.get(0)
423 ).map_err(|_| "Chat not found")?;
424
425 let mut stmt = conn.prepare(
426 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
427 created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
428 FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
429 ).map_err(|e| format!("Failed to prepare: {}", e))?;
430
431 let rows = stmt.query_map(
432 rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
433 |row| {
434 let tags_json: String = row.get(5)?;
435 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
436 Ok(StoredEvent {
437 id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
438 chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
439 tags, reference_id: row.get(6)?,
440 created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
441 mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
442 failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
443 npub: row.get(13)?, preview_metadata: None,
444 })
445 }
446 ).map_err(|e| format!("Failed to query: {}", e))?;
447
448 let mut payments = Vec::new();
449 for row in rows {
450 let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
451 if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "pivx-payment") {
452 payments.push(event);
453 }
454 }
455 Ok(payments)
456}
457
458pub fn get_system_events_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
460 let conn = super::get_db_connection_guard_static()?;
461 let chat_id: i64 = conn.query_row(
462 "SELECT id FROM chats WHERE chat_identifier = ?1",
463 rusqlite::params![conversation_id], |row| row.get(0)
464 ).map_err(|_| "Chat not found")?;
465
466 let mut stmt = conn.prepare(
467 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
468 created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
469 FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
470 ).map_err(|e| format!("Failed to prepare: {}", e))?;
471
472 let rows = stmt.query_map(
473 rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
474 |row| {
475 let tags_json: String = row.get(5)?;
476 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
477 Ok(StoredEvent {
478 id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
479 chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
480 tags, reference_id: row.get(6)?,
481 created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
482 mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
483 failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
484 npub: row.get(13)?, preview_metadata: None,
485 })
486 }
487 ).map_err(|e| format!("Failed to query: {}", e))?;
488
489 let mut events = Vec::new();
490 for row in rows {
491 let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
492 if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "system-event") {
493 events.push(event);
494 }
495 }
496 Ok(events)
497}
498
499fn parse_event_row(row: &rusqlite::Row) -> rusqlite::Result<StoredEvent> {
505 let tags_json: String = row.get(5)?;
506 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
507
508 Ok(StoredEvent {
509 id: row.get(0)?,
510 kind: row.get::<_, i32>(1)? as u16,
511 chat_id: row.get(2)?,
512 user_id: row.get(3)?,
513 content: row.get(4)?,
514 tags,
515 reference_id: row.get(6)?,
516 created_at: row.get::<_, i64>(7)? as u64,
517 received_at: row.get::<_, i64>(8)? as u64,
518 mine: row.get::<_, i32>(9)? != 0,
519 pending: row.get::<_, i32>(10)? != 0,
520 failed: row.get::<_, i32>(11)? != 0,
521 wrapper_event_id: row.get(12)?,
522 npub: row.get(13)?,
523 preview_metadata: row.get(14)?,
524 })
525}
526
527pub async fn get_events(
530 chat_id: i64,
531 kinds: Option<&[u16]>,
532 limit: usize,
533 offset: usize,
534) -> Result<Vec<StoredEvent>, String> {
535 let events: Vec<StoredEvent> = {
536 let conn = super::get_db_connection_guard_static()?;
537
538 if let Some(k) = kinds {
539 let kind_placeholders: String = (0..k.len())
540 .map(|i| format!("?{}", i + 2))
541 .collect::<Vec<_>>()
542 .join(",");
543 let limit_param = k.len() + 2;
544 let offset_param = k.len() + 3;
545
546 let sql = format!(
547 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
548 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
549 FROM events WHERE chat_id = ?1 AND kind IN ({}) \
550 ORDER BY created_at DESC, received_at DESC \
551 LIMIT ?{} OFFSET ?{}",
552 kind_placeholders, limit_param, offset_param
553 );
554
555 let mut stmt = conn.prepare(&sql)
556 .map_err(|e| format!("Failed to prepare events query: {}", e))?;
557
558 match k.len() {
559 1 => {
560 let rows = stmt.query_map(
561 rusqlite::params![chat_id, k[0] as i32, limit as i64, offset as i64],
562 parse_event_row
563 ).map_err(|e| format!("Failed to query events: {}", e))?;
564 rows.filter_map(|r| r.ok()).collect()
565 },
566 2 => {
567 let rows = stmt.query_map(
568 rusqlite::params![chat_id, k[0] as i32, k[1] as i32, limit as i64, offset as i64],
569 parse_event_row
570 ).map_err(|e| format!("Failed to query events: {}", e))?;
571 rows.filter_map(|r| r.ok()).collect()
572 },
573 3 => {
574 let rows = stmt.query_map(
575 rusqlite::params![chat_id, k[0] as i32, k[1] as i32, k[2] as i32, limit as i64, offset as i64],
576 parse_event_row
577 ).map_err(|e| format!("Failed to query events: {}", e))?;
578 rows.filter_map(|r| r.ok()).collect()
579 },
580 _ => return Err("Unsupported number of kinds".to_string()),
581 }
582 } else {
583 let mut stmt = conn.prepare(
584 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
585 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
586 FROM events WHERE chat_id = ?1 \
587 ORDER BY created_at DESC, received_at DESC \
588 LIMIT ?2 OFFSET ?3"
589 ).map_err(|e| format!("Failed to prepare events query: {}", e))?;
590
591 let rows = stmt.query_map(
592 rusqlite::params![chat_id, limit as i64, offset as i64],
593 parse_event_row
594 ).map_err(|e| format!("Failed to query events: {}", e))?;
595 rows.filter_map(|r| r.ok()).collect()
596 }
597 };
598
599 let mut decrypted = Vec::with_capacity(events.len());
601 for mut event in events {
602 if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
603 event.content = crate::crypto::maybe_decrypt(event.content).await
604 .unwrap_or_else(|_| "[Decryption failed]".to_string());
605 }
606 decrypted.push(event);
607 }
608
609 Ok(decrypted)
610}
611
612pub async fn get_related_events(
614 reference_ids: &[String],
615) -> Result<Vec<StoredEvent>, String> {
616 if reference_ids.is_empty() {
617 return Ok(Vec::new());
618 }
619
620 let conn = super::get_db_connection_guard_static()?;
621
622 let placeholders: String = reference_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
623 let sql = format!(
624 "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
625 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
626 FROM events WHERE reference_id IN ({}) \
627 ORDER BY created_at ASC, received_at ASC",
628 placeholders
629 );
630
631 let mut stmt = conn.prepare(&sql)
632 .map_err(|e| format!("Failed to prepare related events query: {}", e))?;
633
634 let params: Vec<&dyn rusqlite::ToSql> = reference_ids.iter()
635 .map(|s| s as &dyn rusqlite::ToSql)
636 .collect();
637
638 let events: Vec<StoredEvent> = stmt.query_map(params.as_slice(), parse_event_row)
639 .map_err(|e| format!("Failed to query related events: {}", e))?
640 .filter_map(|r| r.ok())
641 .collect();
642
643 Ok(events)
644}
645
646pub struct ReplyContext {
648 pub content: String,
649 pub npub: Option<String>,
650 pub has_attachment: bool,
651}
652
653pub async fn get_reply_contexts(
655 message_ids: &[String],
656) -> Result<std::collections::HashMap<String, ReplyContext>, String> {
657 use std::collections::HashMap;
658
659 if message_ids.is_empty() {
660 return Ok(HashMap::new());
661 }
662
663 let (events, edits): (Vec<(String, i32, String, Option<String>)>, Vec<(String, String)>) = {
664 let conn = super::get_db_connection_guard_static()?;
665
666 let placeholders: String = (0..message_ids.len())
667 .map(|i| format!("?{}", i + 1))
668 .collect::<Vec<_>>()
669 .join(",");
670
671 let sql = format!(
673 "SELECT id, kind, content, npub FROM events WHERE id IN ({})",
674 placeholders
675 );
676 let mut stmt = conn.prepare(&sql)
677 .map_err(|e| format!("Failed to prepare reply context query: {}", e))?;
678
679 let params: Vec<&str> = message_ids.iter().map(|s| s.as_str()).collect();
680 let params_dyn: Vec<&dyn rusqlite::ToSql> = params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
681
682 let rows = stmt.query_map(params_dyn.as_slice(), |row| {
683 Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?,
684 row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?))
685 }).map_err(|e| format!("Failed to query reply contexts: {}", e))?;
686 let events_result: Vec<_> = rows.filter_map(|r| r.ok()).collect();
687 drop(stmt);
688
689 let edit_sql = format!(
691 "SELECT reference_id, content FROM events \
692 WHERE kind = {} AND reference_id IN ({}) \
693 ORDER BY created_at DESC, received_at DESC",
694 event_kind::MESSAGE_EDIT, placeholders
695 );
696 let mut edit_stmt = conn.prepare(&edit_sql)
697 .map_err(|e| format!("Failed to prepare edit query: {}", e))?;
698 let edit_rows = edit_stmt.query_map(params_dyn.as_slice(), |row| {
699 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
700 }).map_err(|e| format!("Failed to query edits: {}", e))?;
701 let edits_result: Vec<_> = edit_rows.filter_map(|r| r.ok()).collect();
702
703 (events_result, edits_result)
704 };
705
706 let mut latest_edits: HashMap<String, String> = HashMap::new();
708 for (ref_id, content) in edits {
709 latest_edits.entry(ref_id).or_insert(content);
710 }
711
712 let mut contexts = HashMap::new();
714 for (id, kind, original_content, npub) in events {
715 let has_attachment = kind == event_kind::FILE_ATTACHMENT as i32;
716 let content_to_decrypt = latest_edits.get(&id).cloned().unwrap_or(original_content);
717
718 let decrypted_content = if kind == event_kind::CHAT_MESSAGE as i32
719 || kind == event_kind::PRIVATE_DIRECT_MESSAGE as i32
720 {
721 crate::crypto::maybe_decrypt(content_to_decrypt).await
722 .unwrap_or_else(|_| "[Decryption failed]".to_string())
723 } else {
724 String::new()
725 };
726
727 contexts.insert(id, ReplyContext { content: decrypted_content, npub, has_attachment });
728 }
729
730 Ok(contexts)
731}
732
733pub async fn populate_reply_context(message: &mut Message) -> Result<(), String> {
736 if message.replied_to.is_empty() {
737 return Ok(());
738 }
739
740 let contexts = get_reply_contexts(&[message.replied_to.clone()]).await?;
741
742 if let Some(ctx) = contexts.get(&message.replied_to) {
743 message.replied_to_content = Some(ctx.content.clone());
744 message.replied_to_npub = ctx.npub.clone();
745 message.replied_to_has_attachment = Some(ctx.has_attachment);
746 }
747
748 Ok(())
749}
750
751pub fn is_own_event(event_id: &str) -> bool {
755 let Ok(conn) = super::get_db_connection_guard_static() else {
756 return false;
757 };
758 conn.query_row(
759 "SELECT mine FROM events WHERE id = ?1",
760 [event_id],
761 |row| row.get::<_, i64>(0),
762 )
763 .map(|mine| mine == 1)
764 .unwrap_or(false)
765}
766
767fn extract_tag_from_json(tags_json: &str, key: &str) -> Option<String> {
773 if tags_json.len() <= 2 { return None; }
774 let pattern = format!("[\"{}\"", key);
775 if !tags_json.contains(&pattern) { return None; }
776 let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
777 tags.into_iter()
778 .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
779 .and_then(|tag| tag.into_iter().nth(1))
780}
781
782fn extract_reaction_emoji_url(tags: &[Vec<String>], content: &str) -> Option<String> {
788 if !content.starts_with(':') || !content.ends_with(':') || content.len() < 3 {
789 return None;
790 }
791 let sc = &content[1..content.len() - 1];
792 tags.iter().find_map(|t| {
793 if t.len() >= 3 && t[0] == "emoji" && t[1] == sc {
794 Some(t[2].clone())
795 } else {
796 None
797 }
798 })
799}
800
801fn extract_reply_tag_from_json(tags_json: &str) -> Option<String> {
803 if tags_json.len() <= 2 { return None; }
804 if !tags_json.contains("[\"e\"") { return None; }
805 let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
806 tags.into_iter()
807 .find(|tag| {
808 tag.first().map(|s| s.as_str()) == Some("e")
809 && tag.get(3).map(|s| s.as_str()) == Some("reply")
810 })
811 .and_then(|tag| tag.into_iter().nth(1))
812}
813
814pub async fn get_message_views(
819 chat_id: i64,
820 limit: usize,
821 offset: usize,
822) -> Result<Vec<Message>, String> {
823 use std::collections::HashMap;
824
825 let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
827 let message_events = get_events(chat_id, Some(&message_kinds), limit, offset).await?;
828
829 if message_events.is_empty() {
830 return Ok(Vec::new());
831 }
832
833 let message_ids: Vec<String> = message_events.iter().map(|e| e.id.clone()).collect();
835 let related_events = get_related_events(&message_ids).await?;
836
837 let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
838 let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
839
840 for event in related_events {
841 if let Some(ref_id) = &event.reference_id {
842 match event.kind {
843 k if k == event_kind::REACTION => {
844 let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
845 reactions_by_msg.entry(ref_id.clone()).or_default().push(Reaction {
846 id: event.id.clone(),
847 reference_id: ref_id.clone(),
848 author_id: event.npub.clone().unwrap_or_default(),
849 emoji: event.content.clone(),
850 emoji_url,
851 });
852 }
853 k if k == event_kind::MESSAGE_EDIT => {
854 let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
855 .unwrap_or_else(|_| event.content.clone());
856 let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
857 edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
858 }
859 _ => {}
860 }
861 }
862 }
863
864 for edits in edits_by_msg.values_mut() {
865 edits.sort_by_key(|(ts, _, _)| *ts);
866 }
867
868 let mut attachments_by_msg: HashMap<String, Vec<Attachment>> = HashMap::new();
870 let mut events_needing_legacy_lookup: Vec<String> = Vec::new();
871
872 for event in &message_events {
873 if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
874 continue;
875 }
876 if let Some(json) = event.get_tag("attachments") {
877 if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(json) {
878 if !atts.is_empty() {
879 attachments_by_msg.insert(event.id.clone(), atts);
880 continue;
881 }
882 }
883 }
884 if event.kind == event_kind::FILE_ATTACHMENT {
885 events_needing_legacy_lookup.push(event.id.clone());
886 }
887 }
888
889 if !events_needing_legacy_lookup.is_empty() {
891 if let Ok(conn) = super::get_db_connection_guard_static() {
892 let has_messages: bool = conn.query_row(
893 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='messages'",
894 [], |row| row.get::<_, i32>(0)
895 ).map(|c| c > 0).unwrap_or(false);
896
897 if has_messages {
898 for msg_id in &events_needing_legacy_lookup {
899 if let Ok(json) = conn.query_row::<String, _, _>(
900 "SELECT attachments FROM messages WHERE id = ?1",
901 rusqlite::params![msg_id], |row| row.get(0),
902 ) {
903 if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&json) {
904 attachments_by_msg.insert(msg_id.to_string(), atts);
905 }
906 }
907 }
908 }
909 }
910 }
911
912 let mut messages = Vec::with_capacity(message_events.len());
914 for event in message_events {
915 let replied_to = event.get_reply_reference().unwrap_or("").to_string();
916 let at = event.timestamp_ms();
917 let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
918 let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
919
920 let original_content = if event.kind == event_kind::FILE_ATTACHMENT {
921 String::new()
922 } else {
923 event.content.clone()
924 };
925
926 let original_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
929 let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
930 let mut history = Vec::with_capacity(edits.len() + 1);
931 history.push(crate::types::EditEntry { content: original_content.clone(), edited_at: at });
932 for (ts, c, _) in &edits {
933 history.push(crate::types::EditEntry { content: c.clone(), edited_at: *ts });
934 }
935 let (latest, latest_emoji) = edits.last()
936 .map(|(_, c, e)| (c.clone(), e.clone()))
937 .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
938 (latest, true, Some(history), latest_emoji)
939 } else {
940 (original_content, false, None, original_emoji)
941 };
942
943 let preview_metadata = event.preview_metadata
944 .and_then(|json| serde_json::from_str(&json).ok());
945
946 messages.push(Message {
947 id: event.id, content, replied_to,
948 replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
949 preview_metadata, attachments, reactions, at,
950 pending: event.pending, failed: event.failed, mine: event.mine,
951 npub: event.npub, wrapper_event_id: event.wrapper_event_id,
952 edited, edit_history,
953 emoji_tags,
954 });
955 }
956
957 let reply_ids: Vec<String> = messages.iter()
959 .filter(|m| !m.replied_to.is_empty())
960 .map(|m| m.replied_to.clone())
961 .collect();
962
963 if !reply_ids.is_empty() {
964 let contexts = get_reply_contexts(&reply_ids).await?;
965 for msg in &mut messages {
966 if let Some(ctx) = contexts.get(&msg.replied_to) {
967 msg.replied_to_content = Some(ctx.content.clone());
968 msg.replied_to_npub = ctx.npub.clone();
969 msg.replied_to_has_attachment = Some(ctx.has_attachment);
970 }
971 }
972 }
973
974 Ok(messages)
975}
976
977pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
980 use std::collections::HashMap;
981
982 let message_events: Vec<(String, StoredEvent, String)> = {
984 let conn = super::get_db_connection_guard_static()?;
985 let mut stmt = conn.prepare(
986 "SELECT c.chat_identifier, \
987 e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
988 e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, e.preview_metadata \
989 FROM chats c JOIN events e ON e.rowid = ( \
990 SELECT e2.rowid FROM events e2 WHERE e2.chat_id = c.id \
991 AND e2.kind IN (?1, ?2, ?3) \
992 ORDER BY e2.created_at DESC, e2.received_at DESC LIMIT 1) \
993 WHERE c.chat_type != 1"
994 ).map_err(|e| format!("Failed to prepare: {}", e))?;
995
996 let rows = stmt.query_map(
997 rusqlite::params![
998 event_kind::CHAT_MESSAGE as i32,
999 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1000 event_kind::FILE_ATTACHMENT as i32
1001 ],
1002 |row| {
1003 let chat_id: String = row.get(0)?;
1004 let tags_json: String = row.get(6)?;
1005 let event = StoredEvent {
1006 id: row.get(1)?, kind: row.get::<_, i32>(2)? as u16,
1007 chat_id: row.get(3)?, user_id: row.get(4)?, content: row.get(5)?,
1008 tags: Vec::new(), reference_id: row.get(7)?,
1010 created_at: row.get::<_, i64>(8)? as u64, received_at: row.get::<_, i64>(9)? as u64,
1011 mine: row.get::<_, i32>(10)? != 0, pending: row.get::<_, i32>(11)? != 0,
1012 failed: row.get::<_, i32>(12)? != 0, wrapper_event_id: row.get(13)?,
1013 npub: row.get(14)?, preview_metadata: row.get(15)?,
1014 };
1015 Ok((chat_id, event, tags_json))
1016 }
1017 ).map_err(|e| format!("Failed to query: {}", e))?;
1018 rows.filter_map(|r| r.ok()).collect()
1019 };
1020
1021 if message_events.is_empty() {
1022 return Ok(HashMap::new());
1023 }
1024
1025 let message_ids: Vec<String> = message_events.iter().map(|(_, e, _)| e.id.clone()).collect();
1027 let related_events = get_related_events(&message_ids).await?;
1028
1029 let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1030 let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1031
1032 for event in related_events {
1033 if let Some(ref_id) = &event.reference_id {
1034 match event.kind {
1035 k if k == event_kind::REACTION => {
1036 let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1037 reactions_by_msg.entry(ref_id.clone()).or_default().push(Reaction {
1038 id: event.id.clone(), reference_id: ref_id.clone(),
1039 author_id: event.npub.clone().unwrap_or_default(),
1040 emoji: event.content.clone(),
1041 emoji_url,
1042 });
1043 }
1044 k if k == event_kind::MESSAGE_EDIT => {
1045 let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1046 .unwrap_or_else(|_| event.content.clone());
1047 let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1048 edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1049 }
1050 _ => {}
1051 }
1052 }
1053 }
1054 for edits in edits_by_msg.values_mut() {
1055 edits.sort_by_key(|(ts, _, _)| *ts);
1056 }
1057
1058 let mut attachments_by_msg: HashMap<String, Vec<Attachment>> = HashMap::new();
1060 for (_, event, tags_json) in &message_events {
1061 if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1062 continue;
1063 }
1064 if let Some(val) = extract_tag_from_json(tags_json, "attachments") {
1065 if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&val) {
1066 if !atts.is_empty() {
1067 attachments_by_msg.insert(event.id.clone(), atts);
1068 }
1069 }
1070 }
1071 }
1072
1073 let mut result: HashMap<String, Vec<Message>> = HashMap::new();
1075
1076 for (chat_identifier, event, tags_json) in message_events {
1077 let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1078 let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1079 let replied_to = extract_reply_tag_from_json(&tags_json).unwrap_or_default();
1080
1081 let original_content = if event.kind == event_kind::CHAT_MESSAGE
1083 || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
1084 {
1085 crate::crypto::maybe_decrypt(event.content.clone()).await
1086 .unwrap_or_else(|_| "[Decryption failed]".to_string())
1087 } else {
1088 String::new()
1089 };
1090
1091 let original_emoji = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1092 .map(|t| crate::types::EmojiTag::extract_from_stored(&t))
1093 .unwrap_or_default();
1094 let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1096 let (latest, latest_emoji) = edits.last()
1097 .map(|(_, c, e)| (c.clone(), e.clone()))
1098 .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1099 let history: Vec<crate::types::EditEntry> = std::iter::once(crate::types::EditEntry {
1100 content: original_content, edited_at: event.created_at * 1000,
1101 }).chain(edits.into_iter().map(|(ts, c, _)| crate::types::EditEntry { content: c, edited_at: ts }))
1102 .collect();
1103 (latest, true, Some(history), latest_emoji)
1104 } else {
1105 (original_content, false, None, original_emoji)
1106 };
1107
1108 let preview_metadata = event.preview_metadata
1109 .and_then(|json| serde_json::from_str(&json).ok());
1110
1111 result.entry(chat_identifier).or_default().push(Message {
1112 id: event.id, content, replied_to,
1113 replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1114 preview_metadata, attachments, reactions, at: event.created_at * 1000,
1115 pending: event.pending, failed: event.failed, mine: event.mine,
1116 npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1117 edited, edit_history,
1118 emoji_tags,
1119 });
1120 }
1121
1122 Ok(result)
1123}
1124
1125pub async fn unread_counts() -> Result<std::collections::HashMap<String, u32>, String> {
1132 let conn = super::get_db_connection_guard_static()?;
1133 let mut stmt = conn
1134 .prepare(
1135 "SELECT c.chat_identifier, COUNT(*) AS unread \
1136 FROM events e JOIN chats c ON e.chat_id = c.id \
1137 WHERE e.kind IN (?1, ?2, ?3) AND e.mine = 0 \
1138 AND e.created_at > COALESCE(( \
1139 SELECT MAX(e2.created_at) FROM events e2 \
1140 WHERE e2.chat_id = c.id AND e2.kind IN (?1, ?2, ?3) \
1141 AND (e2.mine = 1 OR e2.id = c.last_read)), 0) \
1142 GROUP BY c.chat_identifier",
1143 )
1144 .map_err(|e| format!("prepare unread_counts: {e}"))?;
1145 let rows = stmt
1146 .query_map(
1147 rusqlite::params![
1148 event_kind::CHAT_MESSAGE as i32,
1149 event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1150 event_kind::FILE_ATTACHMENT as i32
1151 ],
1152 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)),
1153 )
1154 .map_err(|e| format!("query unread_counts: {e}"))?;
1155 let mut out = std::collections::HashMap::new();
1156 for r in rows.flatten() {
1157 out.insert(r.0, r.1);
1158 }
1159 Ok(out)
1160}
1161
1162pub async fn save_chat_messages(chat_id: &str, messages: &[Message]) -> Result<(), String> {
1164 if messages.is_empty() {
1165 return Ok(());
1166 }
1167 for message in messages {
1168 if let Err(e) = save_message(chat_id, message).await {
1169 eprintln!("Failed to save message {}: {}", &message.id[..8.min(message.id.len())], e);
1170 }
1171 }
1172 Ok(())
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177 use super::*;
1178 use crate::stored_event::SystemEventType;
1179
1180 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(71000);
1181
1182 fn make_test_npub(n: u32) -> String {
1183 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1184 let mut payload = vec![b'q'; 58];
1185 let mut x = n as u64;
1186 let mut i = 58;
1187 while x > 0 && i > 0 {
1188 i -= 1;
1189 payload[i] = BECH32[(x as usize) % 32];
1190 x /= 32;
1191 }
1192 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1193 }
1194
1195 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1196 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1197 crate::db::close_database();
1198 let tmp = tempfile::tempdir().unwrap();
1199 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1200 let account = make_test_npub(n);
1201 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1202 crate::db::set_app_data_dir(tmp.path().to_path_buf());
1203 crate::db::set_current_account(account.clone()).unwrap();
1204 crate::db::init_database(&account).unwrap();
1205 (tmp, guard)
1206 }
1207
1208 fn now_secs() -> u64 {
1209 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
1210 }
1211
1212 #[tokio::test]
1216 async fn system_event_stamps_authenticated_time_clamped_to_now() {
1217 let (_tmp, _guard) = init_test_db();
1218 let chat = "channel_ch2_timestamp";
1219 let before = now_secs();
1220 let past = before - 100_000;
1221
1222 save_system_event_at("ev_past", chat, SystemEventType::MemberJoined, "npubX", None, past, None, None).await.unwrap();
1223 save_system_event_at("ev_future", chat, SystemEventType::MemberJoined, "npubX", None, before + 100_000, None, None).await.unwrap();
1224 let after = now_secs();
1225
1226 let evs = get_system_events_for_chat(chat).unwrap();
1227 let past_ev = evs.iter().find(|e| e.id == "ev_past").expect("past event saved");
1228 assert_eq!(past_ev.created_at, past, "historical join keeps its real (authenticated) timestamp");
1229
1230 let fut_ev = evs.iter().find(|e| e.id == "ev_future").expect("future event saved");
1231 assert!(fut_ev.created_at >= before && fut_ev.created_at <= after,
1232 "future-dated event clamped to local now ({} not in {}..={})", fut_ev.created_at, before, after);
1233 }
1234
1235 #[tokio::test]
1238 async fn unread_counts_match_walk_back_semantics() {
1239 let (_tmp, _guard) = init_test_db();
1240 let chat = "npub1contactdm";
1241 let mk = |id: &str, secs: u64, mine: bool| Message {
1243 id: id.into(), content: "x".into(), at: secs * 1000, mine,
1244 npub: (!mine).then(|| "npub1sender".to_string()),
1245 ..Default::default()
1246 };
1247 let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
1248
1249 for i in 0..6u64 {
1251 save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap();
1252 }
1253 assert_eq!(unread().await, 6, "never-read backlog counts all 6");
1254
1255 save_message(chat, &mk("m6", 2000, false)).await.unwrap();
1257 save_message(chat, &mk("m7", 2001, false)).await.unwrap();
1258 assert_eq!(unread().await, 8, "6 backlog + 2 new = 8");
1259
1260 save_message(chat, &mk("mine", 2002, true)).await.unwrap();
1262 assert_eq!(unread().await, 0, "own message = read up to here");
1263
1264 save_message(chat, &mk("m8", 2003, false)).await.unwrap();
1266 assert_eq!(unread().await, 1, "one new after our send");
1267
1268 {
1270 let conn = crate::db::get_write_connection_guard_static().unwrap();
1271 conn.execute(
1272 "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
1273 rusqlite::params!["m8", chat],
1274 ).unwrap();
1275 }
1276 assert_eq!(unread().await, 0, "last_read=m8 clears all");
1277 save_message(chat, &mk("m9", 2004, false)).await.unwrap();
1278 assert_eq!(unread().await, 1, "one arrival after last_read");
1279 }
1280
1281 #[tokio::test]
1285 async fn edit_event_folds_into_history_on_reload() {
1286 let (_tmp, _guard) = init_test_db();
1287 let chat = "channel_edit_fold";
1288 save_message(chat, &Message {
1289 id: "orig1".into(), content: "original".into(), at: 5_000_000,
1290 npub: Some("npub1author".into()), ..Default::default()
1291 }).await.unwrap();
1292
1293 let cid = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
1294 let emoji = vec![crate::types::EmojiTag { shortcode: "wave".into(), url: "u/wave".into() }];
1295 save_edit_event("edit1", "orig1", "edited :wave:", &emoji, cid, None, "npub1author").await.unwrap();
1296
1297 let m = get_message_views(cid, 50, 0).await.unwrap()
1298 .into_iter().find(|m| m.id == "orig1").expect("message reloaded");
1299 assert!(m.edited, "folded edit sets the edited flag");
1300 let h = m.edit_history.as_ref().expect("history reconstructed from the edit event");
1301 assert_eq!(h.len(), 2, "original + one edit");
1302 assert_eq!(h[0].content, "original");
1303 assert_eq!(h[1].content, "edited :wave:");
1304 assert_eq!(m.content, "edited :wave:", "latest revision is the displayed content");
1305 assert_eq!(m.emoji_tags.len(), 1, "the edit's own emoji folds onto the message");
1306 assert_eq!(m.emoji_tags[0].shortcode, "wave");
1307 }
1308}