Skip to main content

vector_core/db/
events.rs

1//! Event storage — save_event for the flat event architecture.
2
3use crate::stored_event::{StoredEvent, event_kind};
4use rusqlite::OptionalExtension;
5use crate::crypto::maybe_encrypt;
6use crate::types::{Message, Attachment, Reaction};
7
8/// Save a StoredEvent to the events table.
9///
10/// Primary storage function for the flat event architecture.
11/// Conditionally encrypts message/edit content based on user setting.
12/// Uses INSERT OR REPLACE with COALESCE to preserve existing wrapper_event_id.
13pub 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    // Conditionally encrypt message/edit content
20    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
60/// Check if an event exists in the database.
61pub 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
70/// Save a reaction as a kind=7 event referencing the message.
71pub 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    // Persist the NIP-30 emoji tag alongside the `e` reference so the
79    // image URL survives reload — pure `arrEmojiPacks` lookup would
80    // fail when the user hasn't yet opened the picker or unsubscribed.
81    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
116// ============================================================================
117// save_message — Message → StoredEvent → DB
118// ============================================================================
119
120/// Save a single message to the database.
121///
122/// Converts Message to StoredEvent and saves via the flat event architecture.
123/// Also saves reactions as separate kind=7 events.
124pub 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    // Save reactions as separate kind=7 events
137    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
150/// Convert a Message to a StoredEvent.
151fn 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    // Millisecond precision tag
161    let ms = message.at % 1000;
162    if ms > 0 {
163        tags.push(vec!["ms".to_string(), ms.to_string()]);
164    }
165
166    // Reply reference
167    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    // Attachments as JSON tag
177    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    // NIP-30 emoji tags — persist so reload from DB still renders the
184    // custom emoji image instead of the literal `:shortcode:`.
185    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
214/// Save a PIVX payment event, resolving chat_id from conversation identifier.
215pub 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
223/// Save a system event (member joined/left/removed) with dedup.
224/// Returns true if inserted, false if duplicate.
225pub 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
238/// Like [`save_system_event_by_id`] but stamps `created_at` from the event's own authenticated timestamp
239/// (clamped to not exceed local now, since the inner author sets it) so a HISTORICALLY-synced presence
240/// (join/leave) sorts at the time it happened, not at ingest-time now. `received_at` stays local now.
241pub 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    // Join attribution (public invites): who minted the link the member joined via, and its label.
249    // Stored as queryable tags so per-link join counts fall out of a tag scan.
250    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    // Author-set timestamp: clamp forward so a future-dated event can't jump ahead of real activity.
259    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
296/// Save a message edit as a kind=16 event referencing the original message.
297pub 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    // Carry NIP-30 emoji tags so a reload renders the edit's custom emoji image
310    // (the reload fold reads the latest edit's tags, not the original message's).
311    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
339/// Delete an event from the events table by ID.
340pub 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
349/// The stored author (npub) of an event, or `None` if the row (or DB) is absent. Lets the
350/// out-of-window moderation-hide path authorize against a paged-out message's real author.
351pub 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
366/// Check if a message/event exists in the database. Returns false if DB unavailable.
367pub 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
379/// Check if a wrapper (giftwrap) event ID exists. Returns false if DB unavailable.
380pub 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
392/// Update the wrapper event ID for an existing event.
393/// Returns true if updated, false if event already had a wrapper_id.
394pub 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
406/// Get message count for a chat.
407pub 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
417/// Get PIVX payment events for a chat.
418pub 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
458/// Get system events (member joined/left) for a chat.
459pub 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
499// ============================================================================
500// Event Read Operations
501// ============================================================================
502
503/// Helper to parse a SQLite row into a StoredEvent.
504fn 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
527/// Get events for a chat with pagination, optionally filtered by kind.
528/// Message/edit content is decrypted via maybe_decrypt.
529pub 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    // Decrypt message content
600    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
612/// Get events that reference specific message IDs (reactions, edits).
613pub 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
646/// Context data for a replied-to message.
647pub struct ReplyContext {
648    pub content: String,
649    pub npub: Option<String>,
650    pub has_attachment: bool,
651}
652
653/// Fetch reply context for a list of message IDs.
654pub 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        // Query original messages
672        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        // Query latest edits
690        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    // Build latest edit map (first = most recent since ordered DESC)
707    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    // Decrypt and build contexts
713    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
733/// Populate reply context for a single message.
734/// Used for real-time messages that don't go through get_message_views.
735pub 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
751/// Whether `event_id` is one of our own messages (`mine = 1`). A reply to our
752/// own message is an implicit ping, so notifications treat it like a direct
753/// @mention (breaks through a muted channel). Missing row → not ours → false.
754pub 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
767// ============================================================================
768// Message Views — compose full Messages from events + reactions + edits
769// ============================================================================
770
771/// Extract a single tag value from raw tags JSON without full allocation.
772fn 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
782/// Extract the NIP-30 `["emoji", shortcode, url]` URL from a stored
783/// reaction's tags. The reaction's content must be `:shortcode:` form
784/// and the matching tag's shortcode must agree — otherwise we get the
785/// URL of a stray emoji tag that doesn't actually represent the
786/// reaction's chosen emoji.
787fn 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
801/// Extract a NIP-10 reply reference ("e" tag with "reply" marker at position 3).
802fn 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
814/// Get message events with reactions, edits, and attachments composed.
815///
816/// This is the main "get messages" function. Queries events, fetches related
817/// reactions/edits, parses attachments, applies edits, resolves reply context.
818pub 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    // Step 1: Get message events (kind 9, 14, 15)
826    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    // Step 2: Get related events (reactions, edits)
834    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    // Step 3: Parse attachments from event tags (+ legacy messages table fallback)
869    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    // Legacy fallback: old migrated events without attachments tag
890    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    // Step 4: Compose Message structs
913    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        // Edits carry their own emoji tags; the newest edit's tags win so the
927        // displayed (latest) content renders its custom emoji, not the original's.
928        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    // Step 5: Reply context
958    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
977/// Get the last message for ALL chats in a single batch query.
978/// Optimized for app startup (chat list sidebar).
979pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
980    use std::collections::HashMap;
981
982    // Step 1: Query last message per chat via correlated subquery
983    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(), // Deferred — parsed on-demand
1009                    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    // Step 2: Related events (reactions, edits)
1026    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    // Step 3: Parse attachments
1059    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    // Step 4: Compose Messages grouped by chat_identifier
1074    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        // Decrypt content
1082        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        // Newest edit's emoji tags win so the latest content renders correctly.
1095        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
1125/// Per-chat unread count, computed straight from the DB so it's correct even when only the last
1126/// message is in RAM (the boot state). Mirrors the in-memory walk-back exactly: unread = non-mine
1127/// messages newer than the most recent "anchor" (our own message OR the `last_read` marker,
1128/// whichever is latest). A never-read chat (empty `last_read`, no own message) counts all its
1129/// non-mine messages. Returns `chat_identifier → count`; chats with 0 unread are omitted.
1130/// Muted/blocked filtering is left to the caller (it lives in RAM state, cheaply).
1131pub 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
1162/// Batch save messages for a chat.
1163pub 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    // C-H2: a presence (join/leave) persisted from HISTORY must keep its authenticated timestamp so it
1213    // sorts where it happened, not at ingest-time "now"; a future-dated one is clamped so it can't jump
1214    // ahead of real activity.
1215    #[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    // The reported bug: unread must accumulate across a restart (when only the last message per
1236    // chat is in RAM). The DB cutoff count must mirror the in-memory walk-back exactly.
1237    #[tokio::test]
1238    async fn unread_counts_match_walk_back_semantics() {
1239        let (_tmp, _guard) = init_test_db();
1240        let chat = "npub1contactdm";
1241        // `at` is ms (created_at = at/1000); use distinct seconds.
1242        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        // 6 contact messages, never opened/read, no own reply → all 6 unread.
1250        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        // 2 more arrive → 8, NOT replaced by 2 (the exact reported symptom).
1256        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        // Our own reply clears it (walk-back stops at the newest mine).
1261        save_message(chat, &mk("mine", 2002, true)).await.unwrap();
1262        assert_eq!(unread().await, 0, "own message = read up to here");
1263
1264        // A contact message after our send is unread again.
1265        save_message(chat, &mk("m8", 2003, false)).await.unwrap();
1266        assert_eq!(unread().await, 1, "one new after our send");
1267
1268        // last_read marker advances the cutoff just like an own message.
1269        {
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    // Edits are event-sourced for BOTH transports: a MESSAGE_EDIT event folds into the target's
1282    // history on reload (latest content + revisions + the edit's own emoji). Community used to
1283    // overwrite the row and lose all of this — this locks in the unified fold.
1284    #[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}