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    // UPSERT (not INSERT OR REPLACE) so a re-save (reaction/edit) UPDATES in place and PRESERVES the
30    // rowid. get_messages_around's (created_at, received_at, rowid) cursor needs a stable final
31    // tiebreak to page through same-timestamp bursts; INSERT OR REPLACE churns the rowid and drops rows.
32    conn.execute(
33        r#"
34        INSERT INTO events (
35            id, kind, chat_id, user_id, content, tags, reference_id,
36            created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata
37        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
38        ON CONFLICT(id) DO UPDATE SET
39            kind = excluded.kind, chat_id = excluded.chat_id, user_id = excluded.user_id,
40            content = excluded.content, tags = excluded.tags, reference_id = excluded.reference_id,
41            created_at = excluded.created_at, received_at = excluded.received_at,
42            mine = excluded.mine, pending = excluded.pending, failed = excluded.failed,
43            wrapper_event_id = COALESCE(excluded.wrapper_event_id, events.wrapper_event_id),
44            npub = excluded.npub, preview_metadata = excluded.preview_metadata
45        "#,
46        rusqlite::params![
47            event.id,
48            event.kind as i32,
49            event.chat_id,
50            event.user_id,
51            content,
52            tags_json,
53            event.reference_id,
54            event.created_at as i64,
55            event.received_at as i64,
56            event.mine as i32,
57            event.pending as i32,
58            event.failed as i32,
59            event.wrapper_event_id,
60            event.npub,
61            event.preview_metadata,
62        ],
63    ).map_err(|e| format!("Failed to save event: {}", e))?;
64
65    Ok(())
66}
67
68/// Extract persisted `["bot", npub]` routing tags (the write side lives in
69/// `message_to_stored_event`).
70fn extract_bot_tags(tags: &[Vec<String>]) -> Vec<String> {
71    tags.iter()
72        .filter(|t| t.len() >= 2 && t[0] == "bot")
73        .map(|t| t[1].clone())
74        .collect()
75}
76
77/// Parse the NIP-40 `["expiration", <unix secs>]` tag, if present. Drives the
78/// self-destruct countdown + purge for messages rehydrated from the DB.
79fn extract_expiration_tag(tags: &[Vec<String>]) -> Option<u64> {
80    tags.iter()
81        .find(|t| t.len() >= 2 && t[0] == "expiration")
82        .and_then(|t| t[1].parse::<u64>().ok())
83}
84
85/// Check if an event exists in the database.
86pub fn event_exists(event_id: &str) -> Result<bool, String> {
87    let conn = super::get_db_connection_guard_static()?;
88    conn.query_row(
89        "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
90        rusqlite::params![event_id],
91        |row| row.get(0),
92    ).map_err(|e| format!("Failed to check event existence: {}", e))
93}
94
95/// Save a reaction as a kind=7 event referencing the message.
96pub async fn save_reaction_event(
97    reaction: &Reaction,
98    chat_id: i64,
99    user_id: Option<i64>,
100    mine: bool,
101    wrapper_event_id: Option<String>,
102) -> Result<(), String> {
103    // Persist the NIP-30 emoji tag alongside the `e` reference so the
104    // image URL survives reload — pure `arrEmojiPacks` lookup would
105    // fail when the user hasn't yet opened the picker or unsubscribed.
106    let mut tags: Vec<Vec<String>> = vec![
107        vec!["e".to_string(), reaction.reference_id.clone()],
108    ];
109    if let Some(url) = &reaction.emoji_url {
110        if reaction.emoji.starts_with(':') && reaction.emoji.ends_with(':') && reaction.emoji.len() >= 3 {
111            let shortcode = &reaction.emoji[1..reaction.emoji.len() - 1];
112            if !shortcode.is_empty() && !url.is_empty() {
113                tags.push(vec!["emoji".to_string(), shortcode.to_string(), url.clone()]);
114            }
115        }
116    }
117    let event = StoredEvent {
118        id: reaction.id.clone(),
119        kind: event_kind::REACTION,
120        chat_id,
121        user_id,
122        content: reaction.emoji.clone(),
123        tags,
124        reference_id: Some(reaction.reference_id.clone()),
125        created_at: std::time::SystemTime::now()
126            .duration_since(std::time::UNIX_EPOCH)
127            .map(|d| d.as_secs()).unwrap_or(0),
128        received_at: std::time::SystemTime::now()
129            .duration_since(std::time::UNIX_EPOCH)
130            .map(|d| d.as_millis() as u64).unwrap_or(0),
131        mine,
132        pending: false,
133        failed: false,
134        wrapper_event_id,
135        npub: Some(reaction.author_id.clone()),
136        preview_metadata: None,
137    };
138    save_event(&event).await
139}
140
141// ============================================================================
142// save_message — Message → StoredEvent → DB
143// ============================================================================
144
145/// Save a single message to the database.
146///
147/// Converts Message to StoredEvent and saves via the flat event architecture.
148/// Also saves reactions as separate kind=7 events.
149pub async fn save_message(chat_id: &str, message: &Message) -> Result<(), String> {
150    let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
151
152    let user_int_id = if let Some(ref npub_str) = message.npub {
153        super::id_cache::get_or_create_user_id(npub_str)?
154    } else {
155        None
156    };
157
158    let event = message_to_stored_event(message, chat_int_id, user_int_id);
159    save_event(&event).await?;
160
161    // Save reactions as separate kind=7 events
162    for reaction in &message.reactions {
163        if !event_exists(&reaction.id)? {
164            let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
165            let is_mine = super::get_current_account()
166                .map(|npub| reaction.author_id == npub)
167                .unwrap_or(false);
168            save_reaction_event(reaction, chat_int_id, user_id, is_mine, None).await?;
169        }
170    }
171
172    Ok(())
173}
174
175/// Convert a Message to a StoredEvent.
176fn message_to_stored_event(message: &Message, chat_id: i64, user_id: Option<i64>) -> StoredEvent {
177    let kind = if !message.attachments.is_empty() {
178        event_kind::FILE_ATTACHMENT
179    } else {
180        event_kind::PRIVATE_DIRECT_MESSAGE
181    };
182
183    let mut tags: Vec<Vec<String>> = Vec::new();
184
185    // Millisecond precision tag
186    let ms = message.at % 1000;
187    if ms > 0 {
188        tags.push(vec!["ms".to_string(), ms.to_string()]);
189    }
190
191    // Reply reference
192    if !message.replied_to.is_empty() {
193        tags.push(vec![
194            "e".to_string(),
195            message.replied_to.clone(),
196            "".to_string(),
197            "reply".to_string(),
198        ]);
199    }
200
201    // Attachments as JSON tag
202    if !message.attachments.is_empty() {
203        if let Ok(json) = serde_json::to_string(&message.attachments) {
204            tags.push(vec!["attachments".to_string(), json]);
205        }
206    }
207
208    // NIP-30 emoji tags — persist so reload from DB still renders the
209    // custom emoji image instead of the literal `:shortcode:`.
210    for et in &message.emoji_tags {
211        tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
212    }
213
214    // Bot routing targets (npubs) — persist so the passive "ran /cmd with
215    // Bot" render survives a reload.
216    for npub in &message.addressed_bots {
217        tags.push(vec!["bot".to_string(), npub.clone()]);
218    }
219
220    // NIP-40 self-destruct expiry — persist so the countdown + purge survive a
221    // reload. Rides the same tags column as every other message-shaped tag.
222    if let Some(exp) = message.expiration {
223        tags.push(vec!["expiration".to_string(), exp.to_string()]);
224    }
225
226    let preview_metadata = message.preview_metadata.as_ref()
227        .and_then(|m| serde_json::to_string(m).ok());
228
229    StoredEvent {
230        id: message.id.clone(),
231        kind,
232        chat_id,
233        user_id,
234        content: message.content.clone(),
235        tags,
236        reference_id: None,
237        created_at: message.at / 1000,
238        received_at: std::time::SystemTime::now()
239            .duration_since(std::time::UNIX_EPOCH)
240            .map(|d| d.as_millis() as u64)
241            .unwrap_or(0),
242        mine: message.mine,
243        pending: message.pending,
244        failed: message.failed,
245        wrapper_event_id: message.wrapper_event_id.clone(),
246        npub: message.npub.clone(),
247        preview_metadata,
248    }
249}
250
251/// Save a PIVX payment event, resolving chat_id from conversation identifier.
252pub async fn save_pivx_payment_event(
253    conversation_id: &str,
254    mut event: StoredEvent,
255) -> Result<(), String> {
256    event.chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
257    save_event(&event).await
258}
259
260/// Save a system event (member joined/left/removed) with dedup.
261/// Returns true if inserted, false if duplicate.
262pub async fn save_system_event_by_id(
263    event_id: &str,
264    conversation_id: &str,
265    event_type: crate::stored_event::SystemEventType,
266    member_npub: &str,
267    member_name: Option<&str>,
268) -> Result<bool, String> {
269    let now_secs = std::time::SystemTime::now()
270        .duration_since(std::time::UNIX_EPOCH)
271        .map(|d| d.as_secs()).unwrap_or(0);
272    save_system_event_at(event_id, conversation_id, event_type, member_npub, member_name, now_secs, None, None).await
273}
274
275/// Like [`save_system_event_by_id`] but stamps `created_at` from the event's own authenticated timestamp
276/// (clamped to not exceed local now, since the inner author sets it) so a HISTORICALLY-synced presence
277/// (join/leave) sorts at the time it happened, not at ingest-time now. `received_at` stays local now.
278pub async fn save_system_event_at(
279    event_id: &str,
280    conversation_id: &str,
281    event_type: crate::stored_event::SystemEventType,
282    member_npub: &str,
283    member_name: Option<&str>,
284    created_at_secs: u64,
285    // Join attribution (public invites): who minted the link the member joined via, and its label.
286    // Stored as queryable tags so per-link join counts fall out of a tag scan.
287    invited_by: Option<&str>,
288    invited_label: Option<&str>,
289) -> Result<bool, String> {
290    let chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
291
292    let now_secs = std::time::SystemTime::now()
293        .duration_since(std::time::UNIX_EPOCH)
294        .map(|d| d.as_secs()).unwrap_or(0);
295    // Author-set timestamp: clamp forward so a future-dated event can't jump ahead of real activity.
296    let created_at = created_at_secs.min(now_secs);
297
298    let display_name = member_name.unwrap_or(member_npub);
299    let content = event_type.display_message(display_name);
300
301    let mut tags: Vec<Vec<String>> = vec![
302        vec!["d".to_string(), "system-event".to_string()],
303        vec!["event-type".to_string(), event_type.as_u8().to_string()],
304        vec!["member".to_string(), member_npub.to_string()],
305    ];
306    if let Some(by) = invited_by {
307        tags.push(vec!["invited-by".to_string(), by.to_string()]);
308        if let Some(l) = invited_label.filter(|l| !l.is_empty()) {
309            tags.push(vec!["invited-label".to_string(), l.to_string()]);
310        }
311    }
312    let tags_json = serde_json::to_string(&tags)
313        .map_err(|e| format!("Failed to serialize tags: {}", e))?;
314
315    let conn = super::get_write_connection_guard_static()?;
316    let rows = conn.execute(
317        r#"INSERT OR IGNORE INTO events (
318            id, kind, chat_id, user_id, content, tags, reference_id,
319            created_at, received_at, mine, pending, failed, wrapper_event_id, npub
320        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
321        rusqlite::params![
322            event_id,
323            event_kind::APPLICATION_SPECIFIC as i32,
324            chat_id, None::<i64>, content, tags_json, None::<String>,
325            created_at as i64, now_secs as i64,
326            0, 0, 0, None::<String>, member_npub,
327        ],
328    ).map_err(|e| format!("Failed to save system event: {}", e))?;
329
330    Ok(rows > 0)
331}
332
333/// Save a message edit as a kind=16 event referencing the original message.
334pub async fn save_edit_event(
335    edit_id: &str,
336    message_id: &str,
337    new_content: &str,
338    emoji_tags: &[crate::types::EmojiTag],
339    chat_id: i64,
340    user_id: Option<i64>,
341    npub: &str,
342) -> Result<(), String> {
343    let now = std::time::SystemTime::now()
344        .duration_since(std::time::UNIX_EPOCH).unwrap();
345
346    // Carry NIP-30 emoji tags so a reload renders the edit's custom emoji image
347    // (the reload fold reads the latest edit's tags, not the original message's).
348    let mut tags = vec![
349        vec!["e".to_string(), message_id.to_string(), "".to_string(), "edit".to_string()],
350    ];
351    for et in emoji_tags {
352        tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
353    }
354
355    let event = StoredEvent {
356        id: edit_id.to_string(),
357        kind: event_kind::MESSAGE_EDIT,
358        chat_id,
359        user_id,
360        content: new_content.to_string(),
361        tags,
362        reference_id: Some(message_id.to_string()),
363        created_at: now.as_secs(),
364        received_at: now.as_millis() as u64,
365        mine: true,
366        pending: false,
367        failed: false,
368        wrapper_event_id: None,
369        npub: Some(npub.to_string()),
370        preview_metadata: None,
371    };
372
373    save_event(&event).await
374}
375
376/// Delete an event from the events table by ID.
377pub async fn delete_event(event_id: &str) -> Result<(), String> {
378    let conn = super::get_write_connection_guard_static()?;
379    // If this row is a chat's read marker, retreat it to the newest surviving event before it FIRST.
380    // A deleted marker would leave `last_read` dangling and collapse the unread anchor (badge stuck
381    // at 99+). The UPDATE fires only when this chat's marker is exactly the row being deleted.
382    if let Ok(Some((chat_row, at))) = conn.query_row(
383        "SELECT chat_id, created_at FROM events WHERE id = ?1",
384        rusqlite::params![event_id],
385        |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
386    ).optional() {
387        conn.execute(
388            "UPDATE chats SET last_read = COALESCE(( \
389                 SELECT id FROM events WHERE chat_id = ?1 AND id != ?2 AND created_at <= ?3 \
390                 ORDER BY created_at DESC, id DESC LIMIT 1), '') \
391             WHERE id = ?1 AND last_read = ?2",
392            rusqlite::params![chat_row, event_id, at],
393        ).map_err(|e| format!("read-marker retreat: {e}"))?;
394    }
395    conn.execute(
396        "DELETE FROM events WHERE id = ?1",
397        rusqlite::params![event_id],
398    ).map_err(|e| format!("Failed to delete event: {}", e))?;
399    Ok(())
400}
401
402/// The stored author (npub) of an event, or `None` if the row (or DB) is absent. Lets the
403/// out-of-window moderation-hide path authorize against a paged-out message's real author.
404pub fn event_author(event_id: &str) -> Result<Option<String>, String> {
405    let conn = match super::get_db_connection_guard_static() {
406        Ok(c) => c,
407        Err(_) => return Ok(None),
408    };
409    conn.query_row(
410        "SELECT npub FROM events WHERE id = ?1",
411        rusqlite::params![event_id],
412        |row| row.get::<_, Option<String>>(0),
413    )
414    .optional()
415    .map(|o| o.flatten())
416    .map_err(|e| format!("Failed to read event author: {}", e))
417}
418
419/// The owning chat identifier, `mine` flag, and stored author (npub) of an event, or
420/// `None` if the row (or DB) is absent. Lets delete-affordance resolution give paged-out
421/// rows the same verdict as resident ones — residency is a cache detail, not a verdict.
422pub fn event_delete_context(event_id: &str) -> Result<Option<(String, bool, Option<String>)>, String> {
423    let conn = match super::get_db_connection_guard_static() {
424        Ok(c) => c,
425        Err(_) => return Ok(None),
426    };
427    conn.query_row(
428        "SELECT c.chat_identifier, e.mine, e.npub \
429         FROM events e JOIN chats c ON c.id = e.chat_id \
430         WHERE e.id = ?1",
431        rusqlite::params![event_id],
432        |row| {
433            Ok((
434                row.get::<_, String>(0)?,
435                row.get::<_, i32>(1)? != 0,
436                row.get::<_, Option<String>>(2)?,
437            ))
438        },
439    )
440    .optional()
441    .map_err(|e| format!("Failed to read event delete context: {}", e))
442}
443
444/// Check if a message/event exists in the database. Returns false if DB unavailable.
445pub fn message_exists_in_db(message_id: &str) -> Result<bool, String> {
446    let conn = match super::get_db_connection_guard_static() {
447        Ok(c) => c,
448        Err(_) => return Ok(false),
449    };
450    conn.query_row(
451        "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
452        rusqlite::params![message_id],
453        |row| row.get(0),
454    ).map_err(|e| format!("Failed to check event existence: {}", e))
455}
456
457/// Check if a wrapper (giftwrap) event ID exists. Returns false if DB unavailable.
458pub fn wrapper_event_exists(wrapper_event_id: &str) -> Result<bool, String> {
459    let conn = match super::get_db_connection_guard_static() {
460        Ok(c) => c,
461        Err(_) => return Ok(false),
462    };
463    conn.query_row(
464        "SELECT EXISTS(SELECT 1 FROM events WHERE wrapper_event_id = ?1)",
465        rusqlite::params![wrapper_event_id],
466        |row| row.get(0),
467    ).map_err(|e| format!("Failed to check wrapper event existence: {}", e))
468}
469
470/// Update the wrapper event ID for an existing event.
471/// Returns true if updated, false if event already had a wrapper_id.
472pub fn update_wrapper_event_id(event_id: &str, wrapper_event_id: &str) -> Result<bool, String> {
473    let conn = match super::get_write_connection_guard_static() {
474        Ok(c) => c,
475        Err(_) => return Ok(false),
476    };
477    let rows = conn.execute(
478        "UPDATE events SET wrapper_event_id = ?1 WHERE id = ?2 AND (wrapper_event_id IS NULL OR wrapper_event_id = '')",
479        rusqlite::params![wrapper_event_id, event_id],
480    ).map_err(|e| format!("Failed to update wrapper event ID: {}", e))?;
481    Ok(rows > 0)
482}
483
484/// Get message count for a chat.
485pub fn get_chat_message_count(chat_id: i64) -> Result<usize, String> {
486    let conn = super::get_db_connection_guard_static()?;
487    // Must count the SAME kinds get_message_views returns (community chat 9, DM 14, file 15). A
488    // narrower set under-counts vs. the rows actually loaded, which latches the frontend cache's
489    // `isFullyLoaded` flag true and wedges the local back-pager — community channels then never
490    // page DB history past the first screen.
491    let count: i64 = conn.query_row(
492        &format!(
493            "SELECT COUNT(*) FROM events WHERE chat_id = ?1 AND kind IN ({}, {}, {})",
494            event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT
495        ),
496        rusqlite::params![chat_id],
497        |row| row.get(0),
498    ).map_err(|e| format!("Failed to count messages: {}", e))?;
499    Ok(count as usize)
500}
501
502/// Get PIVX payment events for a chat.
503pub fn get_pivx_payments_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
504    let conn = super::get_db_connection_guard_static()?;
505    let chat_id: i64 = conn.query_row(
506        "SELECT id FROM chats WHERE chat_identifier = ?1",
507        rusqlite::params![conversation_id], |row| row.get(0)
508    ).map_err(|_| "Chat not found")?;
509
510    let mut stmt = conn.prepare(
511        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
512         created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
513         FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
514    ).map_err(|e| format!("Failed to prepare: {}", e))?;
515
516    let rows = stmt.query_map(
517        rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
518        |row| {
519            let tags_json: String = row.get(5)?;
520            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
521            Ok(StoredEvent {
522                id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
523                chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
524                tags, reference_id: row.get(6)?,
525                created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
526                mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
527                failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
528                npub: row.get(13)?, preview_metadata: None,
529            })
530        }
531    ).map_err(|e| format!("Failed to query: {}", e))?;
532
533    let mut payments = Vec::new();
534    for row in rows {
535        let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
536        if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "pivx-payment") {
537            payments.push(event);
538        }
539    }
540    Ok(payments)
541}
542
543/// Get system events (member joined/left) for a chat.
544pub fn get_system_events_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
545    let conn = super::get_db_connection_guard_static()?;
546    let chat_id: i64 = conn.query_row(
547        "SELECT id FROM chats WHERE chat_identifier = ?1",
548        rusqlite::params![conversation_id], |row| row.get(0)
549    ).map_err(|_| "Chat not found")?;
550
551    let mut stmt = conn.prepare(
552        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
553         created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
554         FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
555    ).map_err(|e| format!("Failed to prepare: {}", e))?;
556
557    let rows = stmt.query_map(
558        rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
559        |row| {
560            let tags_json: String = row.get(5)?;
561            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
562            Ok(StoredEvent {
563                id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
564                chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
565                tags, reference_id: row.get(6)?,
566                created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
567                mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
568                failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
569                npub: row.get(13)?, preview_metadata: None,
570            })
571        }
572    ).map_err(|e| format!("Failed to query: {}", e))?;
573
574    let mut events = Vec::new();
575    for row in rows {
576        let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
577        if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "system-event") {
578            events.push(event);
579        }
580    }
581    Ok(events)
582}
583
584// ============================================================================
585// Event Read Operations
586// ============================================================================
587
588/// Helper to parse a SQLite row into a StoredEvent.
589fn parse_event_row(row: &rusqlite::Row) -> rusqlite::Result<StoredEvent> {
590    let tags_json: String = row.get(5)?;
591    let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
592
593    Ok(StoredEvent {
594        id: row.get(0)?,
595        kind: row.get::<_, i32>(1)? as u16,
596        chat_id: row.get(2)?,
597        user_id: row.get(3)?,
598        content: row.get(4)?,
599        tags,
600        reference_id: row.get(6)?,
601        created_at: row.get::<_, i64>(7)? as u64,
602        received_at: row.get::<_, i64>(8)? as u64,
603        mine: row.get::<_, i32>(9)? != 0,
604        pending: row.get::<_, i32>(10)? != 0,
605        failed: row.get::<_, i32>(11)? != 0,
606        wrapper_event_id: row.get(12)?,
607        npub: row.get(13)?,
608        preview_metadata: row.get(14)?,
609    })
610}
611
612/// Get events for a chat with pagination, optionally filtered by kind.
613/// Message/edit content is decrypted via maybe_decrypt.
614pub async fn get_events(
615    chat_id: i64,
616    kinds: Option<&[u16]>,
617    limit: usize,
618    offset: usize,
619) -> Result<Vec<StoredEvent>, String> {
620    let events: Vec<StoredEvent> = {
621        let conn = super::get_db_connection_guard_static()?;
622
623        if let Some(k) = kinds {
624            let kind_placeholders: String = (0..k.len())
625                .map(|i| format!("?{}", i + 2))
626                .collect::<Vec<_>>()
627                .join(",");
628            let limit_param = k.len() + 2;
629            let offset_param = k.len() + 3;
630
631            let sql = format!(
632                "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
633                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
634                 FROM events WHERE chat_id = ?1 AND kind IN ({}) \
635                 ORDER BY created_at DESC, received_at DESC \
636                 LIMIT ?{} OFFSET ?{}",
637                kind_placeholders, limit_param, offset_param
638            );
639
640            let mut stmt = conn.prepare(&sql)
641                .map_err(|e| format!("Failed to prepare events query: {}", e))?;
642
643            match k.len() {
644                1 => {
645                    let rows = stmt.query_map(
646                        rusqlite::params![chat_id, k[0] as i32, limit as i64, offset as i64],
647                        parse_event_row
648                    ).map_err(|e| format!("Failed to query events: {}", e))?;
649                    rows.filter_map(|r| r.ok()).collect()
650                },
651                2 => {
652                    let rows = stmt.query_map(
653                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, limit as i64, offset as i64],
654                        parse_event_row
655                    ).map_err(|e| format!("Failed to query events: {}", e))?;
656                    rows.filter_map(|r| r.ok()).collect()
657                },
658                3 => {
659                    let rows = stmt.query_map(
660                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, k[2] as i32, limit as i64, offset as i64],
661                        parse_event_row
662                    ).map_err(|e| format!("Failed to query events: {}", e))?;
663                    rows.filter_map(|r| r.ok()).collect()
664                },
665                _ => return Err("Unsupported number of kinds".to_string()),
666            }
667        } else {
668            let mut stmt = conn.prepare(
669                "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
670                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
671                 FROM events WHERE chat_id = ?1 \
672                 ORDER BY created_at DESC, received_at DESC \
673                 LIMIT ?2 OFFSET ?3"
674            ).map_err(|e| format!("Failed to prepare events query: {}", e))?;
675
676            let rows = stmt.query_map(
677                rusqlite::params![chat_id, limit as i64, offset as i64],
678                parse_event_row
679            ).map_err(|e| format!("Failed to query events: {}", e))?;
680            rows.filter_map(|r| r.ok()).collect()
681        }
682    };
683
684    // Decrypt message content
685    let mut decrypted = Vec::with_capacity(events.len());
686    for mut event in events {
687        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
688            event.content = crate::crypto::maybe_decrypt(event.content).await
689                .unwrap_or_else(|_| "[Decryption failed]".to_string());
690        }
691        decrypted.push(event);
692    }
693
694    Ok(decrypted)
695}
696
697/// Get events that reference specific message IDs (reactions, edits).
698pub async fn get_related_events(
699    reference_ids: &[String],
700) -> Result<Vec<StoredEvent>, String> {
701    if reference_ids.is_empty() {
702        return Ok(Vec::new());
703    }
704
705    let conn = super::get_db_connection_guard_static()?;
706
707    let placeholders: String = reference_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
708    let sql = format!(
709        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
710         created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
711         FROM events WHERE reference_id IN ({}) \
712         ORDER BY created_at ASC, received_at ASC",
713        placeholders
714    );
715
716    let mut stmt = conn.prepare(&sql)
717        .map_err(|e| format!("Failed to prepare related events query: {}", e))?;
718
719    let params: Vec<&dyn rusqlite::ToSql> = reference_ids.iter()
720        .map(|s| s as &dyn rusqlite::ToSql)
721        .collect();
722
723    let events: Vec<StoredEvent> = stmt.query_map(params.as_slice(), parse_event_row)
724        .map_err(|e| format!("Failed to query related events: {}", e))?
725        .filter_map(|r| r.ok())
726        .collect();
727
728    Ok(events)
729}
730
731/// Context data for a replied-to message.
732pub struct ReplyContext {
733    pub content: String,
734    pub npub: Option<String>,
735    pub has_attachment: bool,
736    /// Extension of the attachment, when the replied-to message is a file, so
737    /// the reply quote can label the type even when the target is off-screen.
738    pub extension: Option<String>,
739}
740
741/// Fetch reply context for a list of message IDs.
742pub async fn get_reply_contexts(
743    message_ids: &[String],
744) -> Result<std::collections::HashMap<String, ReplyContext>, String> {
745    use std::collections::HashMap;
746
747    if message_ids.is_empty() {
748        return Ok(HashMap::new());
749    }
750
751    let (events, edits): (Vec<(String, i32, String, Option<String>, Option<String>)>, Vec<(String, String)>) = {
752        let conn = super::get_db_connection_guard_static()?;
753
754        let placeholders: String = (0..message_ids.len())
755            .map(|i| format!("?{}", i + 1))
756            .collect::<Vec<_>>()
757            .join(",");
758
759        // Query original messages (tags carry the file-type/name for attachment quotes)
760        let sql = format!(
761            "SELECT id, kind, content, npub, tags FROM events WHERE id IN ({})",
762            placeholders
763        );
764        let mut stmt = conn.prepare(&sql)
765            .map_err(|e| format!("Failed to prepare reply context query: {}", e))?;
766
767        let params: Vec<&str> = message_ids.iter().map(|s| s.as_str()).collect();
768        let params_dyn: Vec<&dyn rusqlite::ToSql> = params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
769
770        let rows = stmt.query_map(params_dyn.as_slice(), |row| {
771            Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?,
772                row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?,
773                row.get::<_, Option<String>>(4)?))
774        }).map_err(|e| format!("Failed to query reply contexts: {}", e))?;
775        let events_result: Vec<_> = rows.filter_map(|r| r.ok()).collect();
776        drop(stmt);
777
778        // Query latest edits
779        let edit_sql = format!(
780            "SELECT reference_id, content FROM events \
781             WHERE kind = {} AND reference_id IN ({}) \
782             ORDER BY created_at DESC, received_at DESC",
783            event_kind::MESSAGE_EDIT, placeholders
784        );
785        let mut edit_stmt = conn.prepare(&edit_sql)
786            .map_err(|e| format!("Failed to prepare edit query: {}", e))?;
787        let edit_rows = edit_stmt.query_map(params_dyn.as_slice(), |row| {
788            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
789        }).map_err(|e| format!("Failed to query edits: {}", e))?;
790        let edits_result: Vec<_> = edit_rows.filter_map(|r| r.ok()).collect();
791
792        (events_result, edits_result)
793    };
794
795    // Build latest edit map (first = most recent since ordered DESC)
796    let mut latest_edits: HashMap<String, String> = HashMap::new();
797    for (ref_id, content) in edits {
798        latest_edits.entry(ref_id).or_insert(content);
799    }
800
801    // Decrypt and build contexts
802    let mut contexts = HashMap::new();
803    for (id, kind, original_content, npub, tags) in events {
804        let has_attachment = kind == event_kind::FILE_ATTACHMENT as i32;
805        let content_to_decrypt = latest_edits.get(&id).cloned().unwrap_or(original_content);
806
807        let decrypted_content = if kind == event_kind::CHAT_MESSAGE as i32
808            || kind == event_kind::PRIVATE_DIRECT_MESSAGE as i32
809        {
810            crate::crypto::maybe_decrypt(content_to_decrypt).await
811                .unwrap_or_else(|_| "[Decryption failed]".to_string())
812        } else {
813            String::new()
814        };
815
816        // Stored kind-15 tags carry the attachments as a JSON array under the "attachments" tag, each
817        // entry with its own `extension` (the rumor's file-type/name tags are not re-stored). Pull the
818        // first attachment's extension so the quote can show the file type.
819        let extension = if has_attachment {
820            tags.as_deref()
821                .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
822                .and_then(|parsed| parsed.into_iter()
823                    .find(|t| t.first().map(|k| k == "attachments").unwrap_or(false))
824                    .and_then(|t| t.into_iter().nth(1)))
825                .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(&json).ok())
826                .and_then(|atts| atts.into_iter().next())
827                .and_then(|a| a.get("extension").and_then(|e| e.as_str()).map(str::to_lowercase))
828                .filter(|e| !e.is_empty())
829        } else {
830            None
831        };
832
833        contexts.insert(id, ReplyContext { content: decrypted_content, npub, has_attachment, extension });
834    }
835
836    Ok(contexts)
837}
838
839/// Populate reply context for a single message.
840/// Used for real-time messages that don't go through get_message_views.
841pub async fn populate_reply_context(message: &mut Message) -> Result<(), String> {
842    if message.replied_to.is_empty() {
843        return Ok(());
844    }
845
846    let contexts = get_reply_contexts(&[message.replied_to.clone()]).await?;
847
848    if let Some(ctx) = contexts.get(&message.replied_to) {
849        message.replied_to_content = Some(ctx.content.clone());
850        message.replied_to_npub = ctx.npub.clone();
851        message.replied_to_has_attachment = Some(ctx.has_attachment);
852        message.replied_to_attachment_extension = ctx.extension.clone();
853    }
854
855    Ok(())
856}
857
858/// Whether `event_id` is one of our own messages (`mine = 1`). A reply to our
859/// own message is an implicit ping, so notifications treat it like a direct
860/// @mention (breaks through a muted channel). Missing row → not ours → false.
861pub fn is_own_event(event_id: &str) -> bool {
862    let Ok(conn) = super::get_db_connection_guard_static() else {
863        return false;
864    };
865    conn.query_row(
866        "SELECT mine FROM events WHERE id = ?1",
867        [event_id],
868        |row| row.get::<_, i64>(0),
869    )
870    .map(|mine| mine == 1)
871    .unwrap_or(false)
872}
873
874// ============================================================================
875// Message Views — compose full Messages from events + reactions + edits
876// ============================================================================
877
878/// Extract a single tag value from raw tags JSON without full allocation.
879fn extract_tag_from_json(tags_json: &str, key: &str) -> Option<String> {
880    if tags_json.len() <= 2 { return None; }
881    let pattern = format!("[\"{}\"", key);
882    if !tags_json.contains(&pattern) { return None; }
883    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
884    tags.into_iter()
885        .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
886        .and_then(|tag| tag.into_iter().nth(1))
887}
888
889
890/// A stored reaction author written as 64-char hex (an early v2 ingest) reads
891/// back as the npub the frontend contract expects — self-heals old rows with no
892/// migration; a bech32 or unknown value passes through untouched.
893fn normalize_reaction_author(author: String) -> String {
894    if author.len() == 64 && author.bytes().all(|b| b.is_ascii_hexdigit()) {
895        if let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(&author) {
896            use nostr_sdk::prelude::ToBech32;
897            if let Ok(npub) = pk.to_bech32() {
898                return npub;
899            }
900        }
901    }
902    author
903}
904/// Extract the NIP-30 `["emoji", shortcode, url]` URL from a stored
905/// reaction's tags. The reaction's content must be `:shortcode:` form
906/// and the matching tag's shortcode must agree — otherwise we get the
907/// URL of a stray emoji tag that doesn't actually represent the
908/// reaction's chosen emoji.
909fn extract_reaction_emoji_url(tags: &[Vec<String>], content: &str) -> Option<String> {
910    if !content.starts_with(':') || !content.ends_with(':') || content.len() < 3 {
911        return None;
912    }
913    let sc = &content[1..content.len() - 1];
914    tags.iter().find_map(|t| {
915        if t.len() >= 3 && t[0] == "emoji" && t[1] == sc {
916            Some(t[2].clone())
917        } else {
918            None
919        }
920    })
921}
922
923/// Extract a NIP-10 reply reference ("e" tag with "reply" marker at position 3).
924fn extract_reply_tag_from_json(tags_json: &str) -> Option<String> {
925    if tags_json.len() <= 2 { return None; }
926    if !tags_json.contains("[\"e\"") { return None; }
927    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
928    tags.into_iter()
929        .find(|tag| {
930            tag.first().map(|s| s.as_str()) == Some("e")
931                && tag.get(3).map(|s| s.as_str()) == Some("reply")
932        })
933        .and_then(|tag| tag.into_iter().nth(1))
934}
935
936/// Get message events with reactions, edits, and attachments composed.
937///
938/// This is the main "get messages" function. Queries events, fetches related
939/// reactions/edits, parses attachments, applies edits, resolves reply context.
940pub async fn get_message_views(
941    chat_id: i64,
942    limit: usize,
943    offset: usize,
944) -> Result<Vec<Message>, String> {
945    // Step 1: Get message events (kind 9, 14, 15)
946    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
947    let message_events = get_events(chat_id, Some(&message_kinds), limit, offset).await?;
948
949    compose_message_views(message_events).await
950}
951
952/// Compose Message views from already-fetched message events (kind 9/14/15):
953/// fetch related reactions/edits, parse attachments, apply edits, resolve reply
954/// context. Shared by `get_message_views` (offset pager) and `get_messages_around`
955/// (anchored window). Input order is preserved in the output.
956async fn compose_message_views(message_events: Vec<StoredEvent>) -> Result<Vec<Message>, String> {
957    use std::collections::HashMap;
958
959    if message_events.is_empty() {
960        return Ok(Vec::new());
961    }
962
963    // Step 2: Get related events (reactions, edits)
964    let message_ids: Vec<String> = message_events.iter().map(|e| e.id.clone()).collect();
965    let related_events = get_related_events(&message_ids).await?;
966
967    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
968    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
969
970    for event in related_events {
971        if let Some(ref_id) = &event.reference_id {
972            match event.kind {
973                k if k == event_kind::REACTION => {
974                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
975                    reactions_by_msg.entry(ref_id.clone()).or_default().push(Reaction {
976                        id: event.id.clone(),
977                        reference_id: ref_id.clone(),
978                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
979                        emoji: event.content.clone(),
980                        emoji_url,
981                    });
982                }
983                k if k == event_kind::MESSAGE_EDIT => {
984                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
985                        .unwrap_or_else(|_| event.content.clone());
986                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
987                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
988                }
989                _ => {}
990            }
991        }
992    }
993
994    for edits in edits_by_msg.values_mut() {
995        edits.sort_by_key(|(ts, _, _)| *ts);
996    }
997
998    // Step 3: Parse attachments from event tags (+ legacy messages table fallback)
999    let mut attachments_by_msg: HashMap<String, Vec<Attachment>> = HashMap::new();
1000    let mut events_needing_legacy_lookup: Vec<String> = Vec::new();
1001
1002    for event in &message_events {
1003        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1004            continue;
1005        }
1006        if let Some(json) = event.get_tag("attachments") {
1007            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(json) {
1008                if !atts.is_empty() {
1009                    attachments_by_msg.insert(event.id.clone(), atts);
1010                    continue;
1011                }
1012            }
1013        }
1014        if event.kind == event_kind::FILE_ATTACHMENT {
1015            events_needing_legacy_lookup.push(event.id.clone());
1016        }
1017    }
1018
1019    // Legacy fallback: old migrated events without attachments tag
1020    if !events_needing_legacy_lookup.is_empty() {
1021        if let Ok(conn) = super::get_db_connection_guard_static() {
1022            let has_messages: bool = conn.query_row(
1023                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='messages'",
1024                [], |row| row.get::<_, i32>(0)
1025            ).map(|c| c > 0).unwrap_or(false);
1026
1027            if has_messages {
1028                for msg_id in &events_needing_legacy_lookup {
1029                    if let Ok(json) = conn.query_row::<String, _, _>(
1030                        "SELECT attachments FROM messages WHERE id = ?1",
1031                        rusqlite::params![msg_id], |row| row.get(0),
1032                    ) {
1033                        if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&json) {
1034                            attachments_by_msg.insert(msg_id.to_string(), atts);
1035                        }
1036                    }
1037                }
1038            }
1039        }
1040    }
1041
1042    // Step 4: Compose Message structs
1043    let mut messages = Vec::with_capacity(message_events.len());
1044    for event in message_events {
1045        let replied_to = event.get_reply_reference().unwrap_or("").to_string();
1046        let at = event.timestamp_ms();
1047        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1048        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1049
1050        let original_content = if event.kind == event_kind::FILE_ATTACHMENT {
1051            String::new()
1052        } else {
1053            event.content.clone()
1054        };
1055
1056        // Edits carry their own emoji tags; the newest edit's tags win so the
1057        // displayed (latest) content renders its custom emoji, not the original's.
1058        let original_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1059        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1060            let mut history = Vec::with_capacity(edits.len() + 1);
1061            history.push(crate::types::EditEntry { content: original_content.clone(), edited_at: at });
1062            for (ts, c, _) in &edits {
1063                history.push(crate::types::EditEntry { content: c.clone(), edited_at: *ts });
1064            }
1065            let (latest, latest_emoji) = edits.last()
1066                .map(|(_, c, e)| (c.clone(), e.clone()))
1067                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1068            (latest, true, Some(history), latest_emoji)
1069        } else {
1070            (original_content, false, None, original_emoji)
1071        };
1072
1073        let preview_metadata = event.preview_metadata
1074            .and_then(|json| serde_json::from_str(&json).ok());
1075
1076        let addressed_bots = extract_bot_tags(&event.tags);
1077        let expiration = extract_expiration_tag(&event.tags);
1078        messages.push(Message {
1079            expiration,
1080            id: event.id, content, replied_to,
1081            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1082            replied_to_attachment_extension: None,
1083            preview_metadata, attachments, reactions, at,
1084            pending: event.pending, failed: event.failed, mine: event.mine,
1085            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1086            edited, edit_history,
1087            emoji_tags,
1088            addressed_bots,
1089        });
1090    }
1091
1092    // Step 5: Reply context
1093    let reply_ids: Vec<String> = messages.iter()
1094        .filter(|m| !m.replied_to.is_empty())
1095        .map(|m| m.replied_to.clone())
1096        .collect();
1097
1098    if !reply_ids.is_empty() {
1099        let contexts = get_reply_contexts(&reply_ids).await?;
1100        for msg in &mut messages {
1101            if let Some(ctx) = contexts.get(&msg.replied_to) {
1102                msg.replied_to_content = Some(ctx.content.clone());
1103                msg.replied_to_npub = ctx.npub.clone();
1104                msg.replied_to_has_attachment = Some(ctx.has_attachment);
1105                msg.replied_to_attachment_extension = ctx.extension.clone();
1106            }
1107        }
1108    }
1109
1110    Ok(messages)
1111}
1112
1113/// Anchored (random-access) message window: load `before` messages up to and
1114/// including the anchor, plus `after` messages strictly newer than it. O(window)
1115/// regardless of how deep the anchor sits in the chat — unlike the offset pager,
1116/// which is O(depth) to reach a far-back message.
1117///
1118/// Returns ASC by `created_at` (oldest first), composed with reactions/edits/
1119/// attachments. Errs if the anchor id isn't in the DB so the caller can fall back.
1120pub async fn get_messages_around(
1121    chat_id: i64,
1122    anchor_id: &str,
1123    before: usize,
1124    after: usize,
1125) -> Result<Vec<Message>, String> {
1126    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1127
1128    let message_events: Vec<StoredEvent> = {
1129        let conn = super::get_db_connection_guard_static()?;
1130
1131        // Resolve the anchor's FULL sort key (created_at, received_at, rowid). Paging by created_at
1132        // alone wedges on a wall of equal timestamps (a message burst): the query keeps returning the
1133        // same newest-N of the cluster, so back-paging stalls before reaching older history. The
1134        // (received_at, rowid) tiebreak — rowid being the unique final key — gives a strict total
1135        // order, so every page steps strictly past the previous, through any same-timestamp cluster.
1136        let (anchor_at, anchor_rt, anchor_rowid): (i64, i64, i64) = conn.query_row(
1137            "SELECT created_at, received_at, rowid FROM events WHERE id = ?1",
1138            rusqlite::params![anchor_id],
1139            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1140        ).map_err(|e| format!("Anchor message not found: {}", e))?;
1141
1142        // Kinds occupy ?2..?4; then ?5 created_at, ?6 received_at, ?7 rowid, ?8 limit.
1143        let kind_placeholders: String = (0..message_kinds.len())
1144            .map(|i| format!("?{}", i + 2))
1145            .collect::<Vec<_>>()
1146            .join(",");
1147        let cols = "id, kind, chat_id, user_id, content, tags, reference_id, \
1148                    created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata";
1149
1150        // Older incl. anchor: strict key <= anchor key; newest-first then reverse to ASC.
1151        let older_sql = format!(
1152            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1153             AND (created_at < ?5 OR (created_at = ?5 AND (received_at < ?6 \
1154                  OR (received_at = ?6 AND rowid <= ?7)))) \
1155             ORDER BY created_at DESC, received_at DESC, rowid DESC LIMIT ?8",
1156            cols, kind_placeholders
1157        );
1158        let mut older_stmt = conn.prepare(&older_sql)
1159            .map_err(|e| format!("Failed to prepare older window query: {}", e))?;
1160        let older_rows = older_stmt.query_map(
1161            rusqlite::params![
1162                chat_id,
1163                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1164                anchor_at, anchor_rt, anchor_rowid, before as i64
1165            ],
1166            parse_event_row,
1167        ).map_err(|e| format!("Failed to query older window: {}", e))?;
1168        let mut older: Vec<StoredEvent> = older_rows.filter_map(|r| r.ok()).collect();
1169        older.reverse(); // DESC -> ASC
1170
1171        // Newer: strictly after the anchor key.
1172        let newer_sql = format!(
1173            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1174             AND (created_at > ?5 OR (created_at = ?5 AND (received_at > ?6 \
1175                  OR (received_at = ?6 AND rowid > ?7)))) \
1176             ORDER BY created_at ASC, received_at ASC, rowid ASC LIMIT ?8",
1177            cols, kind_placeholders
1178        );
1179        let mut newer_stmt = conn.prepare(&newer_sql)
1180            .map_err(|e| format!("Failed to prepare newer window query: {}", e))?;
1181        let newer_rows = newer_stmt.query_map(
1182            rusqlite::params![
1183                chat_id,
1184                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1185                anchor_at, anchor_rt, anchor_rowid, after as i64
1186            ],
1187            parse_event_row,
1188        ).map_err(|e| format!("Failed to query newer window: {}", e))?;
1189        let newer: Vec<StoredEvent> = newer_rows.filter_map(|r| r.ok()).collect();
1190
1191        older.into_iter().chain(newer).collect()
1192    };
1193
1194    // Decrypt message content (mirror get_events).
1195    let mut decrypted = Vec::with_capacity(message_events.len());
1196    for mut event in message_events {
1197        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
1198            event.content = crate::crypto::maybe_decrypt(event.content).await
1199                .unwrap_or_else(|_| "[Decryption failed]".to_string());
1200        }
1201        decrypted.push(event);
1202    }
1203
1204    compose_message_views(decrypted).await
1205}
1206
1207/// Get the last message for ALL chats in a single batch query.
1208/// Optimized for app startup (chat list sidebar).
1209pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
1210    use std::collections::HashMap;
1211
1212    // Step 1: Query last message per chat via correlated subquery
1213    let message_events: Vec<(String, StoredEvent, String)> = {
1214        let conn = super::get_db_connection_guard_static()?;
1215        let mut stmt = conn.prepare(
1216            "SELECT c.chat_identifier, \
1217             e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1218             e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, e.preview_metadata \
1219             FROM chats c JOIN events e ON e.rowid = ( \
1220                 SELECT e2.rowid FROM events e2 WHERE e2.chat_id = c.id \
1221                 AND e2.kind IN (?1, ?2, ?3) \
1222                 ORDER BY e2.created_at DESC, e2.received_at DESC LIMIT 1) \
1223             WHERE c.chat_type != 1"
1224        ).map_err(|e| format!("Failed to prepare: {}", e))?;
1225
1226        let rows = stmt.query_map(
1227            rusqlite::params![
1228                event_kind::CHAT_MESSAGE as i32,
1229                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1230                event_kind::FILE_ATTACHMENT as i32
1231            ],
1232            |row| {
1233                let chat_id: String = row.get(0)?;
1234                let tags_json: String = row.get(6)?;
1235                let event = StoredEvent {
1236                    id: row.get(1)?, kind: row.get::<_, i32>(2)? as u16,
1237                    chat_id: row.get(3)?, user_id: row.get(4)?, content: row.get(5)?,
1238                    tags: Vec::new(), // Deferred — parsed on-demand
1239                    reference_id: row.get(7)?,
1240                    created_at: row.get::<_, i64>(8)? as u64, received_at: row.get::<_, i64>(9)? as u64,
1241                    mine: row.get::<_, i32>(10)? != 0, pending: row.get::<_, i32>(11)? != 0,
1242                    failed: row.get::<_, i32>(12)? != 0, wrapper_event_id: row.get(13)?,
1243                    npub: row.get(14)?, preview_metadata: row.get(15)?,
1244                };
1245                Ok((chat_id, event, tags_json))
1246            }
1247        ).map_err(|e| format!("Failed to query: {}", e))?;
1248        rows.filter_map(|r| r.ok()).collect()
1249    };
1250
1251    if message_events.is_empty() {
1252        return Ok(HashMap::new());
1253    }
1254
1255    // Step 2: Related events (reactions, edits)
1256    let message_ids: Vec<String> = message_events.iter().map(|(_, e, _)| e.id.clone()).collect();
1257    let related_events = get_related_events(&message_ids).await?;
1258
1259    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1260    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1261
1262    for event in related_events {
1263        if let Some(ref_id) = &event.reference_id {
1264            match event.kind {
1265                k if k == event_kind::REACTION => {
1266                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1267                    reactions_by_msg.entry(ref_id.clone()).or_default().push(Reaction {
1268                        id: event.id.clone(), reference_id: ref_id.clone(),
1269                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1270                        emoji: event.content.clone(),
1271                        emoji_url,
1272                    });
1273                }
1274                k if k == event_kind::MESSAGE_EDIT => {
1275                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1276                        .unwrap_or_else(|_| event.content.clone());
1277                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1278                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1279                }
1280                _ => {}
1281            }
1282        }
1283    }
1284    for edits in edits_by_msg.values_mut() {
1285        edits.sort_by_key(|(ts, _, _)| *ts);
1286    }
1287
1288    // Step 3: Parse attachments
1289    let mut attachments_by_msg: HashMap<String, Vec<Attachment>> = HashMap::new();
1290    for (_, event, tags_json) in &message_events {
1291        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1292            continue;
1293        }
1294        if let Some(val) = extract_tag_from_json(tags_json, "attachments") {
1295            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&val) {
1296                if !atts.is_empty() {
1297                    attachments_by_msg.insert(event.id.clone(), atts);
1298                }
1299            }
1300        }
1301    }
1302
1303    // Step 4: Compose Messages grouped by chat_identifier
1304    let mut result: HashMap<String, Vec<Message>> = HashMap::new();
1305
1306    for (chat_identifier, event, tags_json) in message_events {
1307        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1308        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1309        let replied_to = extract_reply_tag_from_json(&tags_json).unwrap_or_default();
1310
1311        // Decrypt content
1312        let original_content = if event.kind == event_kind::CHAT_MESSAGE
1313            || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
1314        {
1315            crate::crypto::maybe_decrypt(event.content.clone()).await
1316                .unwrap_or_else(|_| "[Decryption failed]".to_string())
1317        } else {
1318            String::new()
1319        };
1320
1321        let stored_tags = serde_json::from_str::<Vec<Vec<String>>>(&tags_json).unwrap_or_default();
1322        let original_emoji = crate::types::EmojiTag::extract_from_stored(&stored_tags);
1323        let addressed_bots = extract_bot_tags(&stored_tags);
1324        let expiration = extract_expiration_tag(&stored_tags);
1325        // Newest edit's emoji tags win so the latest content renders correctly.
1326        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1327            let (latest, latest_emoji) = edits.last()
1328                .map(|(_, c, e)| (c.clone(), e.clone()))
1329                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1330            let history: Vec<crate::types::EditEntry> = std::iter::once(crate::types::EditEntry {
1331                content: original_content, edited_at: event.created_at * 1000,
1332            }).chain(edits.into_iter().map(|(ts, c, _)| crate::types::EditEntry { content: c, edited_at: ts }))
1333            .collect();
1334            (latest, true, Some(history), latest_emoji)
1335        } else {
1336            (original_content, false, None, original_emoji)
1337        };
1338
1339        let preview_metadata = event.preview_metadata
1340            .and_then(|json| serde_json::from_str(&json).ok());
1341
1342        result.entry(chat_identifier).or_default().push(Message {
1343            expiration,
1344            id: event.id, content, replied_to,
1345            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1346            replied_to_attachment_extension: None,
1347            preview_metadata, attachments, reactions, at: event.created_at * 1000,
1348            pending: event.pending, failed: event.failed, mine: event.mine,
1349            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1350            edited, edit_history,
1351            emoji_tags,
1352            addressed_bots,
1353        });
1354    }
1355
1356    Ok(result)
1357}
1358
1359/// Per-chat unread count, computed straight from the DB so it's correct even when only the last
1360/// message is in RAM (the boot state). Mirrors the in-memory walk-back exactly: unread = non-mine
1361/// messages newer than the most recent "anchor" (our own message OR the `last_read` marker,
1362/// whichever is latest). A never-read chat (empty `last_read`, no own message) counts all its
1363/// non-mine messages. Returns `chat_identifier → count`; chats with 0 unread are omitted.
1364/// Muted/blocked filtering is left to the caller (it lives in RAM state, cheaply).
1365pub async fn unread_counts() -> Result<std::collections::HashMap<String, u32>, String> {
1366    let conn = super::get_db_connection_guard_static()?;
1367    // The `last_read` anchor is kind-agnostic on purpose: a "read to here" marker can land on a
1368    // system event (kind 30078), and it must still cut the count by its timestamp, or the badge
1369    // wedges at a permanent 99+. Only the own-message anchor is kind-filtered.
1370    let mut stmt = conn
1371        .prepare(
1372            "SELECT c.chat_identifier, COUNT(*) AS unread \
1373             FROM events e JOIN chats c ON e.chat_id = c.id \
1374             WHERE e.kind IN (?1, ?2, ?3) AND e.mine = 0 \
1375               AND e.created_at > COALESCE(( \
1376                     SELECT MAX(e2.created_at) FROM events e2 \
1377                     WHERE e2.chat_id = c.id \
1378                       AND ((e2.mine = 1 AND e2.kind IN (?1, ?2, ?3)) OR e2.id = c.last_read)), 0) \
1379             GROUP BY c.chat_identifier",
1380        )
1381        .map_err(|e| format!("prepare unread_counts: {e}"))?;
1382    let rows = stmt
1383        .query_map(
1384            rusqlite::params![
1385                event_kind::CHAT_MESSAGE as i32,
1386                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1387                event_kind::FILE_ATTACHMENT as i32
1388            ],
1389            |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)),
1390        )
1391        .map_err(|e| format!("query unread_counts: {e}"))?;
1392    let mut out = std::collections::HashMap::new();
1393    for r in rows.flatten() {
1394        out.insert(r.0, r.1);
1395    }
1396    Ok(out)
1397}
1398
1399/// What [`compute_unread_anchor`] decided a chat's read marker should become to surface its newest
1400/// contact message as unread. Computed from the full DB history (RAM may hold only a preview
1401/// message for an unopened community).
1402#[derive(Debug, PartialEq)]
1403pub enum UnreadMark {
1404    /// Nothing to surface: no contact message, or a strictly-newer own message (we spoke last).
1405    NoOp,
1406    /// Reset to the never-read anchor: the target is the chat's earliest message.
1407    Clear,
1408    /// Retreat `last_read` to this event id (the newest message in a strictly earlier second).
1409    Anchor(String),
1410}
1411
1412/// Decide how to mark `chat_identifier` unread. Anchors on the newest message strictly before the
1413/// newest contact message's second — the count query compares whole seconds with a strict `>`, so a
1414/// same-second anchor would leave the target on the boundary and it would read as caught-up.
1415pub async fn compute_unread_anchor(chat_identifier: &str) -> Result<UnreadMark, String> {
1416    let conn = super::get_db_connection_guard_static()?;
1417    let (k0, k1, k2) = (
1418        event_kind::CHAT_MESSAGE as i32,
1419        event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1420        event_kind::FILE_ATTACHMENT as i32,
1421    );
1422    // Newest non-mine message second (the target) and newest overall (to detect we spoke last).
1423    let (target_ts, newest_ts): (Option<i64>, Option<i64>) = conn
1424        .query_row(
1425            "SELECT MAX(CASE WHEN e.mine = 0 THEN e.created_at END), MAX(e.created_at) \
1426             FROM events e JOIN chats c ON e.chat_id = c.id \
1427             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4)",
1428            rusqlite::params![chat_identifier, k0, k1, k2],
1429            |row| Ok((row.get(0)?, row.get(1)?)),
1430        )
1431        .map_err(|e| format!("unread anchor target: {e}"))?;
1432
1433    let target_ts = match target_ts {
1434        Some(t) => t,
1435        None => return Ok(UnreadMark::NoOp), // no contact message to surface
1436    };
1437    if newest_ts.map_or(false, |n| n > target_ts) {
1438        return Ok(UnreadMark::NoOp); // a strictly-newer own message → we spoke last
1439    }
1440
1441    let anchor_id: Option<String> = conn
1442        .query_row(
1443            "SELECT e.id FROM events e JOIN chats c ON e.chat_id = c.id \
1444             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4) AND e.created_at < ?5 \
1445             ORDER BY e.created_at DESC LIMIT 1",
1446            rusqlite::params![chat_identifier, k0, k1, k2, target_ts],
1447            |row| row.get(0),
1448        )
1449        .optional()
1450        .map_err(|e| format!("unread anchor prev: {e}"))?;
1451
1452    Ok(match anchor_id {
1453        Some(id) => UnreadMark::Anchor(id),
1454        None => UnreadMark::Clear,
1455    })
1456}
1457
1458/// Batch save messages for a chat.
1459pub async fn save_chat_messages(chat_id: &str, messages: &[Message]) -> Result<(), String> {
1460    if messages.is_empty() {
1461        return Ok(());
1462    }
1463    for message in messages {
1464        if let Err(e) = save_message(chat_id, message).await {
1465            eprintln!("Failed to save message {}: {}", &message.id[..8.min(message.id.len())], e);
1466        }
1467    }
1468    Ok(())
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473    use super::*;
1474    use crate::stored_event::SystemEventType;
1475
1476    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(71000);
1477
1478    fn make_test_npub(n: u32) -> String {
1479        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1480        let mut payload = vec![b'q'; 58];
1481        let mut x = n as u64;
1482        let mut i = 58;
1483        while x > 0 && i > 0 {
1484            i -= 1;
1485            payload[i] = BECH32[(x as usize) % 32];
1486            x /= 32;
1487        }
1488        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1489    }
1490
1491    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1492        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1493        crate::db::close_database();
1494        // Each test rebinds to a fresh per-account DB; the row-id caches are per-account, so a stale
1495        // entry (e.g. a shared author npub) would point into the prior test's DB and FK-fail the insert.
1496        crate::db::clear_id_caches();
1497        let tmp = tempfile::tempdir().unwrap();
1498        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1499        let account = make_test_npub(n);
1500        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1501        crate::db::set_app_data_dir(tmp.path().to_path_buf());
1502        crate::db::set_current_account(account.clone()).unwrap();
1503        crate::db::init_database(&account).unwrap();
1504        (tmp, guard)
1505    }
1506
1507    fn now_secs() -> u64 {
1508        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
1509    }
1510
1511    // C-H2: a presence (join/leave) persisted from HISTORY must keep its authenticated timestamp so it
1512    // sorts where it happened, not at ingest-time "now"; a future-dated one is clamped so it can't jump
1513    // ahead of real activity.
1514    #[tokio::test]
1515    async fn system_event_stamps_authenticated_time_clamped_to_now() {
1516        let (_tmp, _guard) = init_test_db();
1517        let chat = "channel_ch2_timestamp";
1518        let before = now_secs();
1519        let past = before - 100_000;
1520
1521        save_system_event_at("ev_past", chat, SystemEventType::MemberJoined, "npubX", None, past, None, None).await.unwrap();
1522        save_system_event_at("ev_future", chat, SystemEventType::MemberJoined, "npubX", None, before + 100_000, None, None).await.unwrap();
1523        let after = now_secs();
1524
1525        let evs = get_system_events_for_chat(chat).unwrap();
1526        let past_ev = evs.iter().find(|e| e.id == "ev_past").expect("past event saved");
1527        assert_eq!(past_ev.created_at, past, "historical join keeps its real (authenticated) timestamp");
1528
1529        let fut_ev = evs.iter().find(|e| e.id == "ev_future").expect("future event saved");
1530        assert!(fut_ev.created_at >= before && fut_ev.created_at <= after,
1531            "future-dated event clamped to local now ({} not in {}..={})", fut_ev.created_at, before, after);
1532    }
1533
1534    // Delete-affordance resolution must work on paged-out rows: the events table is the
1535    // fallback source for (chat, mine, author) when a message isn't STATE-resident.
1536    #[tokio::test]
1537    async fn event_delete_context_resolves_from_db() {
1538        let (_tmp, _guard) = init_test_db();
1539        let chat = "npub1contactdc";
1540
1541        let mine_msg = Message { id: "dc_mine".into(), content: "x".into(), at: 1_000, mine: true, ..Default::default() };
1542        let theirs = Message {
1543            id: "dc_theirs".into(), content: "y".into(), at: 2_000, mine: false,
1544            npub: Some("npub1sender".to_string()),
1545            ..Default::default()
1546        };
1547        save_message(chat, &mine_msg).await.unwrap();
1548        save_message(chat, &theirs).await.unwrap();
1549
1550        let (chat_id, mine, _author) = event_delete_context("dc_mine").unwrap().expect("own row resolves");
1551        assert_eq!(chat_id, chat);
1552        assert!(mine);
1553
1554        let (chat_id, mine, author) = event_delete_context("dc_theirs").unwrap().expect("contact row resolves");
1555        assert_eq!(chat_id, chat);
1556        assert!(!mine);
1557        assert_eq!(author.as_deref(), Some("npub1sender"));
1558
1559        assert!(event_delete_context("dc_absent").unwrap().is_none(), "unknown id is None, not an error");
1560    }
1561
1562    // The reported bug: unread must accumulate across a restart (when only the last message per
1563    // chat is in RAM). The DB cutoff count must mirror the in-memory walk-back exactly.
1564    #[tokio::test]
1565    async fn unread_counts_match_walk_back_semantics() {
1566        let (_tmp, _guard) = init_test_db();
1567        let chat = "npub1contactdm";
1568        // `at` is ms (created_at = at/1000); use distinct seconds.
1569        let mk = |id: &str, secs: u64, mine: bool| Message {
1570            id: id.into(), content: "x".into(), at: secs * 1000, mine,
1571            npub: (!mine).then(|| "npub1sender".to_string()),
1572            ..Default::default()
1573        };
1574        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
1575
1576        // 6 contact messages, never opened/read, no own reply → all 6 unread.
1577        for i in 0..6u64 {
1578            save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap();
1579        }
1580        assert_eq!(unread().await, 6, "never-read backlog counts all 6");
1581
1582        // 2 more arrive → 8, NOT replaced by 2 (the exact reported symptom).
1583        save_message(chat, &mk("m6", 2000, false)).await.unwrap();
1584        save_message(chat, &mk("m7", 2001, false)).await.unwrap();
1585        assert_eq!(unread().await, 8, "6 backlog + 2 new = 8");
1586
1587        // Our own reply clears it (walk-back stops at the newest mine).
1588        save_message(chat, &mk("mine", 2002, true)).await.unwrap();
1589        assert_eq!(unread().await, 0, "own message = read up to here");
1590
1591        // A contact message after our send is unread again.
1592        save_message(chat, &mk("m8", 2003, false)).await.unwrap();
1593        assert_eq!(unread().await, 1, "one new after our send");
1594
1595        // last_read marker advances the cutoff just like an own message.
1596        {
1597            let conn = crate::db::get_write_connection_guard_static().unwrap();
1598            conn.execute(
1599                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
1600                rusqlite::params!["m8", chat],
1601            ).unwrap();
1602        }
1603        assert_eq!(unread().await, 0, "last_read=m8 clears all");
1604        save_message(chat, &mk("m9", 2004, false)).await.unwrap();
1605        assert_eq!(unread().await, 1, "one arrival after last_read");
1606    }
1607
1608    // Mark-as-unread anchors from the FULL DB history (a community row often holds only a preview
1609    // message in RAM). The anchor lands strictly before the target's second so the count query's
1610    // strict `>` still counts the newest contact message. Covers the community repro + edge cases.
1611    #[tokio::test]
1612    async fn compute_unread_anchor_covers_the_cases() {
1613        let (_tmp, _guard) = init_test_db();
1614        let mk = |id: &str, secs: u64, mine: bool| Message {
1615            id: id.into(), content: "x".into(), at: secs * 1000, mine,
1616            npub: (!mine).then(|| "npub1sender".to_string()),
1617            ..Default::default()
1618        };
1619        let unread = |chat: &'static str| async move {
1620            unread_counts().await.unwrap().get(chat).copied().unwrap_or(0)
1621        };
1622        let set_lr = |chat: &str, lr: &str| {
1623            let conn = crate::db::get_write_connection_guard_static().unwrap();
1624            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
1625                rusqlite::params![lr, chat]).unwrap();
1626        };
1627
1628        // (A) The community repro: we spoke long ago, they kept talking. Anchor = second-newest
1629        // contact message → exactly one unread, whatever the RAM cache held.
1630        let a = "npub1anchorA";
1631        save_message(a, &mk("a_mine", 1000, true)).await.unwrap();
1632        for i in 0..8u64 { save_message(a, &mk(&format!("a{i}"), 2000 + i, false)).await.unwrap(); }
1633        assert_eq!(compute_unread_anchor(a).await.unwrap(), UnreadMark::Anchor("a6".into()));
1634        set_lr(a, "a6");
1635        assert_eq!(unread(a).await, 1, "A: newest contact message is the sole unread");
1636
1637        // (B) We spoke last → NoOp (no phantom badge, no snap-back jiggle).
1638        let b = "npub1anchorB";
1639        save_message(b, &mk("b0", 2000, false)).await.unwrap();
1640        save_message(b, &mk("b_mine", 2001, true)).await.unwrap();
1641        assert_eq!(compute_unread_anchor(b).await.unwrap(), UnreadMark::NoOp);
1642
1643        // (C) Same-second tail: the two newest share a second. The anchor must skip to a strictly
1644        // earlier second, so both same-second messages surface instead of snapping back to read.
1645        let c = "npub1anchorC";
1646        save_message(c, &mk("c0", 3000, false)).await.unwrap();
1647        save_message(c, &mk("c1", 3005, false)).await.unwrap();
1648        save_message(c, &mk("c2", 3005, false)).await.unwrap();
1649        assert_eq!(compute_unread_anchor(c).await.unwrap(), UnreadMark::Anchor("c0".into()));
1650        set_lr(c, "c0");
1651        assert_eq!(unread(c).await, 2, "C: same-second tail both count");
1652
1653        // (D) The newest contact message is the chat's first → Clear (never-read) → it still counts.
1654        let d = "npub1anchorD";
1655        save_message(d, &mk("d0", 4000, false)).await.unwrap();
1656        assert_eq!(compute_unread_anchor(d).await.unwrap(), UnreadMark::Clear);
1657        set_lr(d, "");
1658        assert_eq!(unread(d).await, 1, "D: lone contact message surfaces");
1659
1660        // (E) No contact message at all (only our own) → NoOp.
1661        let e = "npub1anchorE";
1662        save_message(e, &mk("e_mine", 5000, true)).await.unwrap();
1663        assert_eq!(compute_unread_anchor(e).await.unwrap(), UnreadMark::NoOp);
1664    }
1665
1666    // Regression: a "read to here" marker that lands on a system event (kind 30078, not a counted
1667    // kind, e.g. the windowed jump-reveal path marking off the raw tail) must still clear unread.
1668    // The anchor keys off the marker row's time whatever its kind, so it can't wedge at 99+.
1669    #[tokio::test]
1670    async fn unread_clears_when_last_read_is_a_system_event() {
1671        let (_tmp, _guard) = init_test_db();
1672        let chat = "npub1sysevtdm";
1673        let mk = |id: &str, secs: u64| Message {
1674            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
1675            npub: Some("npub1sender".to_string()), ..Default::default()
1676        };
1677        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
1678
1679        // 5 contact messages, never read → all unread. No own message, so the ONLY viable anchor
1680        // is last_read (this is the case that used to stick at a permanent count).
1681        for i in 0..5u64 {
1682            save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap();
1683        }
1684        assert_eq!(unread().await, 5, "never-read backlog");
1685
1686        // A system event is the newest row (a join notification, after every contact message).
1687        save_system_event_at("sysev", chat, SystemEventType::MemberJoined, "npubX", None, 2000, None, None).await.unwrap();
1688
1689        // last_read pinned to that system event (kind 30078) — the reported bad state.
1690        {
1691            let conn = crate::db::get_write_connection_guard_static().unwrap();
1692            conn.execute(
1693                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
1694                rusqlite::params!["sysev", chat],
1695            ).unwrap();
1696        }
1697        assert_eq!(unread().await, 0, "read marker on a system event still clears the badge");
1698    }
1699
1700    // Deleting a message adjusts unread correctly: removing an UNREAD message drops the count by
1701    // one, removing the read MARKER retreats it to the prior message (never collapses to 99+), and
1702    // removing the last read message clears the marker without over-counting.
1703    #[tokio::test]
1704    async fn deleting_a_message_adjusts_unread_without_wedging() {
1705        let (_tmp, _guard) = init_test_db();
1706        let chat = "npub1delunread";
1707        let mk = |id: &str, secs: u64| Message {
1708            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
1709            npub: Some("npub1sender".to_string()), ..Default::default()
1710        };
1711        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
1712        let set_marker = |id: &str| {
1713            let conn = crate::db::get_write_connection_guard_static().unwrap();
1714            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
1715                rusqlite::params![id, chat]).unwrap();
1716        };
1717        let marker = || -> String {
1718            let conn = crate::db::get_db_connection_guard_static().unwrap();
1719            conn.query_row("SELECT last_read FROM chats WHERE chat_identifier = ?1",
1720                rusqlite::params![chat], |r| r.get::<_, String>(0)).unwrap()
1721        };
1722
1723        // m0..m5, read up to m1 → m2,m3,m4,m5 unread.
1724        for i in 0..6u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap(); }
1725        set_marker("m1");
1726        assert_eq!(unread().await, 4, "m2..m5 unread");
1727
1728        // Delete an UNREAD mid-block message → badge drops by one, marker untouched.
1729        delete_event("m3").await.unwrap();
1730        assert_eq!(unread().await, 3, "one unread deleted → badge minus one");
1731        assert_eq!(marker(), "m1", "deleting an unread message leaves the marker alone");
1732
1733        // Delete the read MARKER → retreats to the prior surviving message (m0), unread unchanged.
1734        delete_event("m1").await.unwrap();
1735        assert_eq!(marker(), "m0", "marker retreats to the newest survivor before it");
1736        assert_eq!(unread().await, 3, "retreat keeps the count, no collapse to 99+");
1737
1738        // Delete the last surviving read message → marker clears, still exactly the unread block.
1739        delete_event("m0").await.unwrap();
1740        assert_eq!(marker(), "", "no earlier survivor → marker clears");
1741        assert_eq!(unread().await, 3, "cleared marker counts only the true unread survivors");
1742    }
1743
1744    // Edits are event-sourced for BOTH transports: a MESSAGE_EDIT event folds into the target's
1745    // history on reload (latest content + revisions + the edit's own emoji). Community used to
1746    // overwrite the row and lose all of this — this locks in the unified fold.
1747    #[tokio::test]
1748    async fn edit_event_folds_into_history_on_reload() {
1749        let (_tmp, _guard) = init_test_db();
1750        let chat = "channel_edit_fold";
1751        save_message(chat, &Message {
1752            id: "orig1".into(), content: "original".into(), at: 5_000_000,
1753            npub: Some("npub1author".into()), ..Default::default()
1754        }).await.unwrap();
1755
1756        let cid = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
1757        let emoji = vec![crate::types::EmojiTag { shortcode: "wave".into(), url: "u/wave".into() }];
1758        save_edit_event("edit1", "orig1", "edited :wave:", &emoji, cid, None, "npub1author").await.unwrap();
1759
1760        let m = get_message_views(cid, 50, 0).await.unwrap()
1761            .into_iter().find(|m| m.id == "orig1").expect("message reloaded");
1762        assert!(m.edited, "folded edit sets the edited flag");
1763        let h = m.edit_history.as_ref().expect("history reconstructed from the edit event");
1764        assert_eq!(h.len(), 2, "original + one edit");
1765        assert_eq!(h[0].content, "original");
1766        assert_eq!(h[1].content, "edited :wave:");
1767        assert_eq!(m.content, "edited :wave:", "latest revision is the displayed content");
1768        assert_eq!(m.emoji_tags.len(), 1, "the edit's own emoji folds onto the message");
1769        assert_eq!(m.emoji_tags[0].shortcode, "wave");
1770    }
1771}